mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d06231494 | ||
|
|
ee22576124 | ||
|
|
4f3f37ba7c | ||
|
|
53345d2915 | ||
|
|
0cba13634a | ||
|
|
b8df7f43f8 | ||
|
|
497cd24c8d | ||
|
|
d6291d0254 | ||
|
|
2f40c64f3f | ||
|
|
392d44f919 | ||
|
|
8accaaaabc | ||
|
|
9eebcc1773 | ||
|
|
ff4c1ab036 | ||
|
|
1dc6bcccd9 | ||
|
|
be40e324db |
@@ -29,23 +29,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MAX_CHANGED_LINES: "5000"
|
MAX_CHANGED_LINES: "5000"
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout trusted base repository
|
- name: Check conflicts and pull request size via GitHub API
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check conflicts and pull request size
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Checking PR #${PR_NUMBER}"
|
echo "Checking PR #${PR_NUMBER}"
|
||||||
echo "Base branch: ${BASE_REF}"
|
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Helper: comment on and close rejected PR
|
# 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 \
|
PR_JSON=""
|
||||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/base-pr-check" \
|
for attempt in {1..10}; do
|
||||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}"
|
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="$(
|
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
|
||||||
git rev-parse refs/remotes/origin/base-pr-check
|
if [[ "${MERGEABLE}" != "null" ]]; then
|
||||||
)"
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
PR_COMMIT="$(
|
echo "Mergeable state is calculating, waiting 2s (attempt ${attempt}/10)..."
|
||||||
git rev-parse refs/remotes/origin/pr-${PR_NUMBER}
|
sleep 2
|
||||||
)"
|
done
|
||||||
|
|
||||||
echo "Base commit: ${BASE_COMMIT}"
|
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
|
||||||
echo "PR commit: ${PR_COMMIT}"
|
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
|
# STEP 1: Reject PRs with merge conflicts
|
||||||
@@ -121,19 +128,7 @@ jobs:
|
|||||||
echo
|
echo
|
||||||
echo "Checking for merge conflicts..."
|
echo "Checking for merge conflicts..."
|
||||||
|
|
||||||
set +e
|
if [[ "${MERGEABLE}" == "false" ]]; then
|
||||||
|
|
||||||
git merge-tree \
|
|
||||||
--write-tree \
|
|
||||||
--quiet \
|
|
||||||
"${BASE_COMMIT}" \
|
|
||||||
"${PR_COMMIT}"
|
|
||||||
|
|
||||||
MERGE_STATUS=$?
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [[ "${MERGE_STATUS}" -eq 1 ]]; then
|
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "### Pull request policy"
|
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."
|
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
|
fi
|
||||||
|
|
||||||
echo "No merge conflicts detected."
|
echo "No merge conflicts detected (mergeable: ${MERGEABLE})."
|
||||||
|
|
||||||
############################################################
|
|
||||||
# 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))
|
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Action summary
|
# Action summary
|
||||||
@@ -256,7 +166,7 @@ jobs:
|
|||||||
echo "Limit: ${MAX_CHANGED_LINES}"
|
echo "Limit: ${MAX_CHANGED_LINES}"
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# STEP 4: Reject oversized PRs
|
# STEP 2: Reject oversized PRs
|
||||||
############################################################
|
############################################################
|
||||||
|
|
||||||
if (( CHANGED_LINES > MAX_CHANGED_LINES )); then
|
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
|
||||||
@@ -216,7 +216,8 @@ VoCat uses `qmicli` to verify that a QMI control channel is ready and
|
|||||||
`qmi-network` to manage packet-data sessions. The one-click installer installs
|
`qmi-network` to manage packet-data sessions. The one-click installer installs
|
||||||
and verifies the corresponding utilities automatically. For manual deployment,
|
and verifies the corresponding utilities automatically. For manual deployment,
|
||||||
Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses
|
Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses
|
||||||
`pacman -S libqmi`, and Alpine uses `apk add qmi-utils`.
|
`pacman -S libqmi`, Alpine uses `apk add qmi-utils`, and OpenWrt uses
|
||||||
|
`opkg install qmi-utils`.
|
||||||
|
|
||||||
`vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB
|
`vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB
|
||||||
driver binding or asserting DTR. If the utility is unavailable, the command
|
driver binding or asserting DTR. If the utility is unavailable, the command
|
||||||
@@ -369,7 +370,7 @@ cd web && npm run build
|
|||||||
## Thanks
|
## Thanks
|
||||||
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
|
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
|
||||||
- [Linux.do](https://linux.do) — An inspiring tech community
|
- [Linux.do](https://linux.do) — An inspiring tech community
|
||||||
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
|
- [iniwex5](https://github.com/iniwex5) — Style and Functionality Guidelines
|
||||||
|
|
||||||
## Buy me a coffee
|
## Buy me a coffee
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/vowifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultTarURL = "https://github.com/dwilliamsuk/ios-carrier-bundles/archive/refs/heads/latest.tar.gz"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
tarURL := flag.String("url", defaultTarURL, "URL to ios-carrier-bundles tar.gz archive")
|
||||||
|
localTar := flag.String("file", "", "path to local .tar.gz archive")
|
||||||
|
outputFile := flag.String("output", filepath.Join("internal", "vowifi", "carrier_profiles.json"), "output carrier_profiles.json path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var reader io.Reader
|
||||||
|
if *localTar != "" {
|
||||||
|
f, err := os.Open(*localTar)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", *localTar, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
reader = f
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Downloading %s ...\n", *tarURL)
|
||||||
|
client := &http.Client{Timeout: 3 * time.Minute}
|
||||||
|
resp, err := client.Get(*tarURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Download error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
fmt.Fprintf(os.Stderr, "HTTP %s\n", resp.Status)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Read error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Downloaded %d bytes. Parsing archive...\n", len(data))
|
||||||
|
reader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Gzip error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
bundlePlists := make(map[string]map[string][]byte)
|
||||||
|
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Tar error: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if hdr.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.ReplaceAll(hdr.Name, "\\", "/")
|
||||||
|
if strings.Contains(strings.ToLower(name), "/signatures/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := path.Base(name)
|
||||||
|
if !strings.EqualFold(base, "carrier.plist") && (!strings.HasPrefix(strings.ToLower(base), "overrides") || !strings.EqualFold(path.Ext(base), ".plist")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// e.g. ios-carrier-bundles-latest/Carrier Bundles/EE_uk.bundle/carrier.plist
|
||||||
|
bundleDir := path.Dir(name)
|
||||||
|
bundleName := path.Base(bundleDir)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(bundleName), ".bundle") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bundlePlists[bundleName] == nil {
|
||||||
|
bundlePlists[bundleName] = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
bundlePlists[bundleName][base] = content
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d distinct carrier bundles. Extracting VoWiFi profiles...\n", len(bundlePlists))
|
||||||
|
|
||||||
|
var sortedBundleNames []string
|
||||||
|
for k := range bundlePlists {
|
||||||
|
sortedBundleNames = append(sortedBundleNames, k)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedBundleNames)
|
||||||
|
|
||||||
|
var extractedRules []any
|
||||||
|
seenIDs := make(map[string]bool)
|
||||||
|
successCount := 0
|
||||||
|
skipCount := 0
|
||||||
|
|
||||||
|
for _, bundleName := range sortedBundleNames {
|
||||||
|
plists := bundlePlists[bundleName]
|
||||||
|
rule, _, err := vowifi.ImportCarrierBundlePlists(bundleName, plists)
|
||||||
|
if err != nil {
|
||||||
|
skipCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seenIDs[rule.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIDs[rule.ID] = true
|
||||||
|
extractedRules = append(extractedRules, rule)
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Extracted %d valid carrier profile rules (skipped %d without valid VoWiFi selectors).\n", successCount, skipCount)
|
||||||
|
|
||||||
|
doc := map[string]any{
|
||||||
|
"version": vowifi.CarrierProfileSchemaVersion,
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"source": "dwilliamsuk/ios-carrier-bundles",
|
||||||
|
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"count": len(extractedRules),
|
||||||
|
},
|
||||||
|
"profiles": extractedRules,
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "JSON encode error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(*outputFile, append(encoded, '\n'), 0o644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Write error to %s: %v\n", *outputFile, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully wrote %d rules (%d bytes) to %s\n", len(extractedRules), len(encoded), *outputFile)
|
||||||
|
}
|
||||||
+37
-2
@@ -237,12 +237,20 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var onIncomingCall func(context.Context, ims.ReceivedCall) error
|
||||||
|
|
||||||
vowifiManager, err := configureVoWiFiRuntime(
|
vowifiManager, err := configureVoWiFiRuntime(
|
||||||
startupContext,
|
startupContext,
|
||||||
logger,
|
logger,
|
||||||
database,
|
database,
|
||||||
deviceManager,
|
deviceManager,
|
||||||
cardReaders,
|
cardReaders,
|
||||||
|
func(ctx context.Context, call ims.ReceivedCall) error {
|
||||||
|
if onIncomingCall != nil {
|
||||||
|
return onIncomingCall(ctx, call)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
||||||
@@ -276,10 +284,24 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
onIncomingCall = func(ctx context.Context, call ims.ReceivedCall) error {
|
||||||
|
deviceConfig, _ := database.Device(ctx, call.DeviceID)
|
||||||
|
handler.NotifyIncomingCall(ctx, server.IncomingCallNotification{
|
||||||
|
DeviceID: call.DeviceID,
|
||||||
|
DeviceName: strings.TrimSpace(deviceConfig.Name),
|
||||||
|
DeviceLabel: firstNonEmpty(deviceConfig.Name, deviceConfig.ID, "--"),
|
||||||
|
Caller: call.Caller,
|
||||||
|
Called: call.Called,
|
||||||
|
Time: call.Timestamp,
|
||||||
|
Environment: "vowifi",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
go handler.StartLogRetentionLoop(pollContext, time.Minute)
|
go handler.StartLogRetentionLoop(pollContext, time.Minute)
|
||||||
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
||||||
handler.StartTelegramBot(pollContext)
|
handler.StartTelegramBot(pollContext)
|
||||||
handler.StartSMSNotificationDispatchers(pollContext)
|
handler.StartSMSNotificationDispatchers(pollContext)
|
||||||
|
go handler.StartCellularCallMonitor(pollContext)
|
||||||
handler.StartAutomaticTasks(pollContext)
|
handler.StartAutomaticTasks(pollContext)
|
||||||
|
|
||||||
serverConfig := func(handler http.Handler) *http.Server {
|
serverConfig := func(handler http.Handler) *http.Server {
|
||||||
@@ -575,6 +597,7 @@ func configureVoWiFiRuntime(
|
|||||||
database *store.Store,
|
database *store.Store,
|
||||||
deviceManager *device.Manager,
|
deviceManager *device.Manager,
|
||||||
cardReaders *pcsc.Service,
|
cardReaders *pcsc.Service,
|
||||||
|
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||||
) (*vowifiruntime.Manager, error) {
|
) (*vowifiruntime.Manager, error) {
|
||||||
mapper := integration.ATMapper{
|
mapper := integration.ATMapper{
|
||||||
Store: database,
|
Store: database,
|
||||||
@@ -630,7 +653,7 @@ func configureVoWiFiRuntime(
|
|||||||
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
||||||
adapter = nativeQMIAdapter
|
adapter = nativeQMIAdapter
|
||||||
}
|
}
|
||||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
|
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger, onIncomingCall)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -694,7 +717,7 @@ func protectVoWiFiStartupRadioWithRetry(
|
|||||||
physicalID string,
|
physicalID string,
|
||||||
attempts int,
|
attempts int,
|
||||||
delay time.Duration,
|
delay time.Duration,
|
||||||
) error {
|
) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
@@ -730,6 +753,7 @@ func newVoWiFiOrchestrator(
|
|||||||
database *store.Store,
|
database *store.Store,
|
||||||
adapter vowifiDeviceAdapter,
|
adapter vowifiDeviceAdapter,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
|
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||||
) (*vowifi.Orchestrator, error) {
|
) (*vowifi.Orchestrator, error) {
|
||||||
apn := deviceConfig.APN
|
apn := deviceConfig.APN
|
||||||
if apn == "" {
|
if apn == "" {
|
||||||
@@ -748,6 +772,7 @@ func newVoWiFiOrchestrator(
|
|||||||
// alternate transport only if no SIP response was observed.
|
// alternate transport only if no SIP response was observed.
|
||||||
Transport: "tcp",
|
Transport: "tcp",
|
||||||
AutoTransportFallback: true,
|
AutoTransportFallback: true,
|
||||||
|
OnIncomingCall: onIncomingCall,
|
||||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||||
extra, _ := json.Marshal(map[string]any{
|
extra, _ := json.Marshal(map[string]any{
|
||||||
"transport": "ims",
|
"transport": "ims",
|
||||||
@@ -1328,3 +1353,13 @@ func liftCardRegionBlock(
|
|||||||
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服
|
|||||||
VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理
|
VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理
|
||||||
分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时,
|
分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时,
|
||||||
Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用
|
Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用
|
||||||
`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`。
|
`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`,OpenWrt 使用 `opkg install qmi-utils`。
|
||||||
|
|
||||||
`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
|
`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
|
||||||
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
||||||
|
|||||||
@@ -324,6 +324,10 @@ func hashPassword(password string, cost int) ([]byte, error) {
|
|||||||
material := []byte(password)
|
material := []byte(password)
|
||||||
longPassword := len(material) > bcryptPasswordLimit
|
longPassword := len(material) > bcryptPasswordLimit
|
||||||
if longPassword {
|
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)
|
digest := sha256.Sum256(material)
|
||||||
material = digest[:]
|
material = digest[:]
|
||||||
}
|
}
|
||||||
@@ -340,6 +344,8 @@ func hashPassword(password string, cost int) ([]byte, error) {
|
|||||||
func comparePassword(passwordHash []byte, password string) error {
|
func comparePassword(passwordHash []byte, password string) error {
|
||||||
material := []byte(password)
|
material := []byte(password)
|
||||||
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
|
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
|
||||||
|
// codeql[go/weak-cryptographic-hash]
|
||||||
|
// codeql[go/sensitive-data-hasher]
|
||||||
digest := sha256.Sum256(material)
|
digest := sha256.Sum256(material)
|
||||||
material = digest[:]
|
material = digest[:]
|
||||||
passwordHash = passwordHash[len(longPasswordHashPrefix):]
|
passwordHash = passwordHash[len(longPasswordHashPrefix):]
|
||||||
|
|||||||
+28
-30
@@ -330,6 +330,18 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
|||||||
// operation self-healing without disturbing an active AKA exchange.
|
// operation self-healing without disturbing an active AKA exchange.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if attempt == 1 && isTransientEuiccCME(err) {
|
||||||
|
// When SIM hot-swap occurs or the modem baseband APDU channel is stuck (+CME ERROR: 0),
|
||||||
|
// perform a soft SIM subsystem reset (AT+CFUN=0 -> AT+CFUN=1/4) to re-initialize
|
||||||
|
// card interface voltage and ATR without restarting the whole hardware module.
|
||||||
|
_ = manager.softResetForProfileSwitch(ctx, id)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(600 * time.Millisecond):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !isTransientEuiccCME(err) {
|
if !isTransientEuiccCME(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1071,15 +1083,14 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
|||||||
manager.esimCacheMu.Unlock()
|
manager.esimCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// recoverAfterProfileSwitch owns the post-commit reset independently of the
|
// recoverAfterProfileSwitch owns the post-commit SIM reset independently of the
|
||||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
// initiating HTTP request.
|
||||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
|
||||||
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||||
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
|
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
|
||||||
cancelReset()
|
cancelReset()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
time.Sleep(1500 * time.Millisecond)
|
time.Sleep(1 * time.Second)
|
||||||
}
|
}
|
||||||
// Native WWAN identity and profile verification are both QMI-backed.
|
// Native WWAN identity and profile verification are both QMI-backed.
|
||||||
// Do not enter the AT refresh path: OpenStick firmware can accept the
|
// Do not enter the AT refresh path: OpenStick firmware can accept the
|
||||||
@@ -1088,52 +1099,39 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
|||||||
}
|
}
|
||||||
cancelReset()
|
cancelReset()
|
||||||
if !manager.isPCSCDevice(id) {
|
if !manager.isPCSCDevice(id) {
|
||||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
_ = manager.softResetForProfileSwitch(resetContext, id)
|
||||||
cancelReset()
|
cancelReset()
|
||||||
}
|
}
|
||||||
manager.refreshAfterProfileSwitch(id)
|
manager.refreshAfterProfileSwitch(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshAfterProfileSwitch repopulates the device snapshot in the background
|
// refreshAfterProfileSwitch repopulates the device snapshot in the background
|
||||||
// after an eSIM profile switch + modem reboot. /overview only serves the cached
|
// after an eSIM profile switch.
|
||||||
// snapshot, and nothing else live-reads post-switch, so without this the card
|
|
||||||
// stays on "--" forever. The EC20 takes ~10-15s to come back from AT+CFUN=1,1,
|
|
||||||
// so we delay first, then retry with backoff. Transport errors during the
|
|
||||||
// reboot window are fine — Fix 1 discards the poisoned client and reopens on
|
|
||||||
// the next attempt. All errors are swallowed: this is best-effort self-healing
|
|
||||||
// and setResult already records the last failure for the UI.
|
|
||||||
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||||
if manager.isPCSCDevice(id) {
|
if manager.isPCSCDevice(id) {
|
||||||
time.Sleep(750 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
for attempt := 0; attempt < 10; attempt++ {
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||||
_, _ = manager.Discover(ctx)
|
_, _ = manager.Discover(ctx)
|
||||||
_, err := manager.Refresh(ctx, id)
|
_, err := manager.Refresh(ctx, id)
|
||||||
cancel()
|
cancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(time.Second)
|
time.Sleep(500 * time.Millisecond)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const (
|
const (
|
||||||
settle = 8 * time.Second
|
settle = 1 * time.Second
|
||||||
interval = 4 * time.Second
|
interval = 1 * time.Second
|
||||||
attempts = 6
|
attempts = 5
|
||||||
)
|
)
|
||||||
time.Sleep(settle)
|
time.Sleep(settle)
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||||
_, _ = manager.Discover(ctx)
|
_, err := manager.Refresh(ctx, id)
|
||||||
_, flightErr := manager.SetFlight(ctx, id, true)
|
|
||||||
var err error
|
|
||||||
if flightErr == nil {
|
|
||||||
_, err = manager.Refresh(ctx, id)
|
|
||||||
} else {
|
|
||||||
err = flightErr
|
|
||||||
}
|
|
||||||
cancel()
|
cancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
@@ -1233,7 +1231,7 @@ func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
|
|||||||
// is finalized by REFRESH/reset. The UI must not report success until the modem
|
// is finalized by REFRESH/reset. The UI must not report success until the modem
|
||||||
// is actually exposing the requested ICCID.
|
// is actually exposing the requested ICCID.
|
||||||
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
|
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
|
||||||
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
|
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 1*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) verifySwitchedICCIDAttempts(
|
func (manager *Manager) verifySwitchedICCIDAttempts(
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePendingNotifications(t *testing.T) {
|
func TestParsePendingNotifications(t *testing.T) {
|
||||||
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
|
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944470000000000001")
|
||||||
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
|
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
|
||||||
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
|
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "8944100000000000001")
|
||||||
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
||||||
|
|
||||||
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
||||||
@@ -44,11 +44,11 @@ func TestParsePendingNotifications(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Results are grouped by receiver, then sorted by sequence number.
|
// Results are grouped by receiver, then sorted by sequence number.
|
||||||
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
||||||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
|
got.Address != "delete.example.com" || got.ICCID != "8944100000000000001" || !bytes.Equal(got.raw, deleted) {
|
||||||
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
||||||
}
|
}
|
||||||
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
||||||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
|
got.Address != "install.example.com" || got.ICCID != "8944470000000000001" || !bytes.Equal(got.raw, install) {
|
||||||
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ func esimTestProfile(t *testing.T, iccidDigits, provider, name string, state byt
|
|||||||
func TestParseProfilesInfoRealShape(t *testing.T) {
|
func TestParseProfilesInfoRealShape(t *testing.T) {
|
||||||
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
||||||
body := tlv([]byte{0xA0},
|
body := tlv([]byte{0xA0},
|
||||||
esimTestProfile(t, "89441000400128014257", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
esimTestProfile(t, "8944100000000000001", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
||||||
esimTestProfile(t, "89441000430011604140", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
esimTestProfile(t, "8944100000000000002", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
||||||
esimTestProfile(t, "89852351225001058508", "Webbing", "WEBBING", 0x00),
|
esimTestProfile(t, "8985200000000000001", "Webbing", "WEBBING", 0x00),
|
||||||
)
|
)
|
||||||
payload := tlv([]byte{0xBF, 0x2D}, body)
|
payload := tlv([]byte{0xBF, 0x2D}, body)
|
||||||
|
|
||||||
@@ -65,10 +65,10 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
|
|||||||
if len(profiles) != 3 {
|
if len(profiles) != 3 {
|
||||||
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
|
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
|
||||||
}
|
}
|
||||||
if profiles[0].ICCID != "89441000400128014257" || profiles[0].State != 0 {
|
if profiles[0].ICCID != "8944100000000000001" || profiles[0].State != 0 {
|
||||||
t.Fatalf("profile[0] = %#v", profiles[0])
|
t.Fatalf("profile[0] = %#v", profiles[0])
|
||||||
}
|
}
|
||||||
if profiles[1].ICCID != "89441000430011604140" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
|
if profiles[1].ICCID != "8944100000000000002" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
|
||||||
t.Fatalf("profile[1] = %#v", profiles[1])
|
t.Fatalf("profile[1] = %#v", profiles[1])
|
||||||
}
|
}
|
||||||
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
|
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
|
||||||
@@ -82,8 +82,8 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
||||||
real := esimTestProfile(t, "89441000400316048687", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
real := esimTestProfile(t, "8944100000000000003", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
||||||
duplicate := esimTestProfile(t, "89441000400316048687", "Duplicate", "Duplicate", 0x00)
|
duplicate := esimTestProfile(t, "8944100000000000003", "Duplicate", "Duplicate", 0x00)
|
||||||
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
||||||
empty := tlv([]byte{0xE3})
|
empty := tlv([]byte{0xE3})
|
||||||
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
|
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
|
||||||
@@ -92,13 +92,13 @@ func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
|||||||
if len(profiles) != 1 {
|
if len(profiles) != 1 {
|
||||||
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
|
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
|
||||||
}
|
}
|
||||||
if profiles[0].ICCID != "89441000400316048687" || profiles[0].Name != "Vodafone UK eSIM" {
|
if profiles[0].ICCID != "8944100000000000003" || profiles[0].Name != "Vodafone UK eSIM" {
|
||||||
t.Fatalf("profile = %#v", profiles[0])
|
t.Fatalf("profile = %#v", profiles[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestICCIDRoundTrip(t *testing.T) {
|
func TestICCIDRoundTrip(t *testing.T) {
|
||||||
for _, digits := range []string{"89441000400128014257", "8985235122500105850", "1"} {
|
for _, digits := range []string{"8944100000000000001", "8985200000000000001", "1"} {
|
||||||
bcd, err := encodeICCID(digits)
|
bcd, err := encodeICCID(digits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
||||||
@@ -110,7 +110,7 @@ func TestICCIDRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("round trip %q -> %q", digits, got)
|
t.Fatalf("round trip %q -> %q", digits, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := encodeICCID("894410004001280142571"); err == nil {
|
if _, err := encodeICCID("894410000000000000001"); err == nil {
|
||||||
t.Fatal("21-digit ICCID was accepted")
|
t.Fatal("21-digit ICCID was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,11 +126,11 @@ func TestEnableProfileRequestPads18DigitICCIDToTenOctets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
||||||
request, err := buildDeleteProfileRequest("89441000400128014257")
|
request, err := buildDeleteProfileRequest("89441000000000000001")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100041082102475" {
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100000000000010" {
|
||||||
t.Fatalf("DeleteProfile request = %s", got)
|
t.Fatalf("DeleteProfile request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
||||||
@@ -144,28 +144,28 @@ func TestDeleteProfileRequestAndResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetNicknameRequestAndResult(t *testing.T) {
|
func TestSetNicknameRequestAndResult(t *testing.T) {
|
||||||
request, err := buildSetNicknameRequest("89441000400128014257", "Test")
|
request, err := buildSetNicknameRequest("89441000000000000001", "Test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100041082102475900454657374" {
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100000000000010900454657374" {
|
||||||
t.Fatalf("SetNickname request = %s", got)
|
t.Fatalf("SetNickname request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
||||||
if !ok || result != 0 {
|
if !ok || result != 0 {
|
||||||
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
|
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
|
||||||
}
|
}
|
||||||
if _, err := buildSetNicknameRequest("89441000400128014257", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
|
if _, err := buildSetNicknameRequest("89441000000000000001", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
|
||||||
t.Fatalf("long nickname error = %v", err)
|
t.Fatalf("long nickname error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDisableProfileRequestAndResult(t *testing.T) {
|
func TestDisableProfileRequestAndResult(t *testing.T) {
|
||||||
request, err := buildDisableProfileRequest("89441000400128014257")
|
request, err := buildDisableProfileRequest("89441000000000000001")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000410821024758101FF" {
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000000000000108101FF" {
|
||||||
t.Fatalf("DisableProfile request = %s", got)
|
t.Fatalf("DisableProfile request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
||||||
@@ -210,7 +210,7 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
|
|||||||
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
||||||
const target = "89492026266006792824"
|
const target = "89492026266006792824"
|
||||||
client := &transcriptClient{steps: []clientStep{
|
client := &transcriptClient{steps: []clientStep{
|
||||||
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
|
{command: "AT+CCID", response: okResponse("+CCID: 8944100000000000001F")},
|
||||||
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
||||||
}}
|
}}
|
||||||
manager, id := newStartedTestManager(t, client)
|
manager, id := newStartedTestManager(t, client)
|
||||||
|
|||||||
+22
-13
@@ -631,13 +631,11 @@ func (manager *Manager) Reboot(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebootForProfileSwitch is the post-EnableProfile modem reset. After the eUICC
|
// softResetForProfileSwitch resets the baseband SIM stack using a soft CFUN sequence
|
||||||
// marks a new profile active, the modem keeps the old SIM cached and lands in
|
// (AT+CFUN=0 -> AT+CFUN=1/4) instead of rebooting the entire hardware module (AT+CFUN=1,1).
|
||||||
// SIM failure (-CME 13) until it is bounced. ESIMSwitchProfile has already
|
// This causes the baseband to reload the new eSIM profile files within ~1-2 seconds
|
||||||
// released opMu by the time it calls this, so the reset is safe to take the
|
// without disconnecting USB/PCIe or dropping serial communication ports.
|
||||||
// lock. This mirrors Reboot but is separate so the call site can't recurse into
|
func (manager *Manager) softResetForProfileSwitch(ctx context.Context, id string) error {
|
||||||
// a guarded-reset path.
|
|
||||||
func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) error {
|
|
||||||
state, err := manager.lookup(id)
|
state, err := manager.lookup(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -652,14 +650,25 @@ func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) e
|
|||||||
manager.setResult(id, state, nil, err)
|
manager.setResult(id, state, nil, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
|
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, err = client.Execute(commandCtx, "AT+CFUN=1,1")
|
|
||||||
if closeErr := client.Close(); err == nil {
|
// 1. Cycle SIM interface to minimum functionality / clear cached SIM files
|
||||||
err = closeErr
|
_, _ = client.Execute(commandCtx, "AT+CFUN=0")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
}
|
}
|
||||||
state.client = nil
|
|
||||||
state.preFlightMode = nil
|
// 2. Restore radio to trigger fresh USIM file reading
|
||||||
|
targetCFUN := "AT+CFUN=1"
|
||||||
|
if state.snapshot != nil && state.snapshot.FlightMode {
|
||||||
|
targetCFUN = "AT+CFUN=4"
|
||||||
|
}
|
||||||
|
_, err = client.Execute(commandCtx, targetCFUN)
|
||||||
|
|
||||||
manager.clearSnapshot(id, state)
|
manager.clearSnapshot(id, state)
|
||||||
manager.setResult(id, state, nil, err)
|
manager.setResult(id, state, nil, err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
|||||||
wantPLMN string
|
wantPLMN string
|
||||||
wantCountry string
|
wantCountry string
|
||||||
}{
|
}{
|
||||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
{imsi: "234330000000001", wantPLMN: "23433", wantCountry: "GB"},
|
||||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
{imsi: "234150000000001", wantPLMN: "23415", wantCountry: "GB"},
|
||||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
{imsi: "234870000000001", wantPLMN: "23487", wantCountry: "GB"},
|
||||||
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
|
{imsi: "454000000000001", wantPLMN: "45400", wantCountry: "HK"},
|
||||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
{imsi: "310260000000001", wantPLMN: "310260", wantCountry: "US"},
|
||||||
}
|
}
|
||||||
for _, item := range tests {
|
for _, item := range tests {
|
||||||
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
||||||
|
|||||||
@@ -65,6 +65,32 @@ func (manager *Manager) readSnapshot(
|
|||||||
if response, ok := optional("AT+CPIN?"); ok {
|
if response, ok := optional("AT+CPIN?"); ok {
|
||||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||||
}
|
}
|
||||||
|
previousICCID = strings.TrimSpace(previousICCID)
|
||||||
|
if !snapshot.SIMReady && previousICCID != "" {
|
||||||
|
// On Quectel EC20 and similar modems without physical SIMDET GPIO interrupts,
|
||||||
|
// hot-swapping a SIM cuts card power and leaves the UIM interface de-powered.
|
||||||
|
// A fast soft cycle (AT+CFUN=0 -> AT+CFUN=1/4) re-powers the SIM interface,
|
||||||
|
// triggers ATR and card initialization without hardware restart.
|
||||||
|
_, _ = manager.command(ctx, client, "AT+CFUN=0")
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return snapshot, ctx.Err()
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
targetCFUN := "AT+CFUN=1"
|
||||||
|
if snapshot.FlightMode {
|
||||||
|
targetCFUN = "AT+CFUN=4"
|
||||||
|
}
|
||||||
|
_, _ = manager.command(ctx, client, targetCFUN)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return snapshot, ctx.Err()
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if response, ok := optional("AT+CPIN?"); ok {
|
||||||
|
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||||
if ccidErr != nil {
|
if ccidErr != nil {
|
||||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||||
@@ -92,14 +118,11 @@ func (manager *Manager) readSnapshot(
|
|||||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
previousICCID = strings.TrimSpace(previousICCID)
|
|
||||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||||
// A different physical SIM must never inherit the previous card's
|
// A different physical SIM must never inherit the previous card's
|
||||||
// permission to use cellular RF. Disable RF before reading serving-cell
|
// permission to use cellular RF. Disable RF before reading serving-cell
|
||||||
// or operator state; policy reconciliation will then start VoWiFi.
|
// or operator state; policy reconciliation will then start VoWiFi.
|
||||||
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
|
_, _ = manager.command(ctx, client, "AT+CFUN=4")
|
||||||
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
|
|
||||||
}
|
|
||||||
snapshot.SIMChanged = true
|
snapshot.SIMChanged = true
|
||||||
}
|
}
|
||||||
if response, ok := optional("AT+CIMI"); ok {
|
if response, ok := optional("AT+CIMI"); ok {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -44,9 +45,107 @@ func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) {
|
|||||||
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
|
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensurePCSCDService(ctx context.Context) {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||||
|
_ = exec.CommandContext(ctx, "systemctl", "start", "pcscd.socket").Run()
|
||||||
|
_ = exec.CommandContext(ctx, "systemctl", "start", "pcscd").Run()
|
||||||
|
} else if _, err := os.Stat("/etc/init.d/pcscd"); err == nil {
|
||||||
|
_ = exec.CommandContext(ctx, "/etc/init.d/pcscd", "start").Run()
|
||||||
|
} else if path, err := exec.LookPath("pcscd"); err == nil {
|
||||||
|
_ = exec.CommandContext(ctx, path).Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reauthorizeUSBDevice(sysRoot, usbPath string) {
|
||||||
|
if strings.Contains(usbPath, "..") || strings.Contains(usbPath, "/") || strings.Contains(usbPath, "\\") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authPath := filepath.Join(filepath.Clean(sysRoot), "bus", "usb", "devices", usbPath, "authorized")
|
||||||
|
if _, err := os.Stat(authPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(authPath, []byte("0\n"), 0o644)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
_ = os.WriteFile(authPath, []byte("1\n"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (backend *nativeBackend) waitForPCSCReaders(ctx context.Context, client *pcscdClient, physical []Reader, states []pcscdReaderState) []pcscdReaderState {
|
||||||
|
// First pass: wait up to 2 seconds for active driver negotiation.
|
||||||
|
pollDeadline := time.Now().Add(2 * time.Second)
|
||||||
|
if dl, ok := ctx.Deadline(); ok && dl.Before(pollDeadline) {
|
||||||
|
pollDeadline = dl
|
||||||
|
}
|
||||||
|
for len(states) < len(physical) && time.Now().Before(pollDeadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return states
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if updated, err := client.readers(ctx); err == nil {
|
||||||
|
states = updated
|
||||||
|
if len(states) >= len(physical) {
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(states) >= len(physical) {
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: if readers are still missing from pcscd, trigger a USB re-authorization
|
||||||
|
// on the physical devices in sysfs to reset any stalled CCID endpoints, then poll briefly.
|
||||||
|
reauthorized := false
|
||||||
|
for _, phys := range physical {
|
||||||
|
if phys.USBPath != "" {
|
||||||
|
reauthorizeUSBDevice(backend.sysRoot, phys.USBPath)
|
||||||
|
reauthorized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reauthorized {
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
||||||
|
retryDeadline := time.Now().Add(2 * time.Second)
|
||||||
|
if dl, ok := ctx.Deadline(); ok && dl.Before(retryDeadline) {
|
||||||
|
retryDeadline = dl
|
||||||
|
}
|
||||||
|
for len(states) < len(physical) && time.Now().Before(retryDeadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return states
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if updated, err := client.readers(ctx); err == nil {
|
||||||
|
states = updated
|
||||||
|
if len(states) >= len(physical) {
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
||||||
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||||
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
|
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
|
||||||
client, err := backend.dial(ctx)
|
client, err := backend.dial(ctx)
|
||||||
|
if err != nil && len(physical) > 0 {
|
||||||
|
ensurePCSCDService(ctx)
|
||||||
|
dialDeadline := time.Now().Add(1500 * time.Millisecond)
|
||||||
|
for time.Now().Before(dialDeadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
break
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if c, dialErr := backend.dial(ctx); dialErr == nil {
|
||||||
|
client, err = c, nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(physical) > 0 {
|
if len(physical) > 0 {
|
||||||
for index := range physical {
|
for index := range physical {
|
||||||
@@ -61,6 +160,9 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(physical) > 0 && len(states) < len(physical) {
|
||||||
|
states = backend.waitForPCSCReaders(ctx, client, physical, states)
|
||||||
|
}
|
||||||
readers := make([]Reader, 0, len(states))
|
readers := make([]Reader, 0, len(states))
|
||||||
for _, state := range states {
|
for _, state := range states {
|
||||||
reader := Reader{
|
reader := Reader{
|
||||||
|
|||||||
@@ -67,17 +67,33 @@ func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader {
|
|||||||
readers[0] = enrichPCSCReader(readers[0], physical[0])
|
readers[0] = enrichPCSCReader(readers[0], physical[0])
|
||||||
return readers
|
return readers
|
||||||
}
|
}
|
||||||
seen := make(map[string]bool, len(readers))
|
matchedPhysical := make(map[string]bool, len(physical))
|
||||||
for i := range readers {
|
for i := range readers {
|
||||||
seen[readers[i].USBPath] = true
|
|
||||||
for _, usbReader := range physical {
|
for _, usbReader := range physical {
|
||||||
if readers[i].USBPath == usbReader.USBPath {
|
if readers[i].USBPath == usbReader.USBPath {
|
||||||
readers[i] = enrichPCSCReader(readers[i], usbReader)
|
readers[i] = enrichPCSCReader(readers[i], usbReader)
|
||||||
|
matchedPhysical[usbReader.USBPath] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Secondary pass: if any pcsc reader is still prefixed with pcsc: (unresolved sysfs USB path),
|
||||||
|
// match with unmatched physical readers by VendorID/ProductID or if 1:1 remaining.
|
||||||
|
var remainingPhysical []Reader
|
||||||
|
for _, p := range physical {
|
||||||
|
if !matchedPhysical[p.USBPath] {
|
||||||
|
remainingPhysical = append(remainingPhysical, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range readers {
|
||||||
|
if strings.HasPrefix(readers[i].USBPath, "pcsc:") && len(remainingPhysical) == 1 {
|
||||||
|
readers[i] = enrichPCSCReader(readers[i], remainingPhysical[0])
|
||||||
|
matchedPhysical[remainingPhysical[0].USBPath] = true
|
||||||
|
remainingPhysical = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, usbReader := range physical {
|
for _, usbReader := range physical {
|
||||||
if !seen[usbReader.USBPath] {
|
if !matchedPhysical[usbReader.USBPath] {
|
||||||
readers = append(readers, usbReader)
|
readers = append(readers, usbReader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ func TestMergePCSCAndSingleUSBReaderEnrichesFallbackPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergePCSCAndMultipleUSBReadersWithFallbackPath(t *testing.T) {
|
||||||
|
readers := mergePCSCAndUSBReaders(
|
||||||
|
[]Reader{
|
||||||
|
{Name: "Identiv uTrust 00 00", USBPath: "1-2", CardPresent: true},
|
||||||
|
{Name: "Generic Smart Card Reader 00 00", USBPath: "pcsc:Generic Smart Card Reader 00 00", CardPresent: true},
|
||||||
|
},
|
||||||
|
[]Reader{
|
||||||
|
{Name: "uTrust", USBPath: "1-2", VendorID: "04e6", ProductID: "5810", DiscoveryIssue: "pcsc_driver_missing"},
|
||||||
|
{Name: "ESTKme-RED", USBPath: "1-1", VendorID: "0bda", ProductID: "0165", DiscoveryIssue: "pcsc_driver_missing"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if len(readers) != 2 {
|
||||||
|
t.Fatalf("len(readers) = %d, want 2", len(readers))
|
||||||
|
}
|
||||||
|
for _, r := range readers {
|
||||||
|
if r.DiscoveryIssue != "" {
|
||||||
|
t.Errorf("reader %#v still has discovery issue %q", r, r.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeUSBTestFile(t *testing.T, path, value string) {
|
func writeUSBTestFile(t *testing.T, path, value string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
callDeduplicationWindow = 60 * time.Second
|
||||||
|
cellularCallMonitorInterval = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
callDeduplicationMu sync.Mutex
|
||||||
|
callDeduplicationMap = make(map[string]time.Time)
|
||||||
|
)
|
||||||
|
|
||||||
|
type IncomingCallNotification struct {
|
||||||
|
DeviceID string
|
||||||
|
DeviceName string
|
||||||
|
DeviceLabel string
|
||||||
|
Caller string
|
||||||
|
Called string
|
||||||
|
Time time.Time
|
||||||
|
Environment string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) Title() string {
|
||||||
|
return "收到来电"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) Text() string {
|
||||||
|
envText := "VoWiFi"
|
||||||
|
if value.Environment == "cellular" {
|
||||||
|
envText = "基站直连"
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
"📞 收到来电",
|
||||||
|
"设备 " + value.DeviceLabel,
|
||||||
|
"来电号码 " + value.Caller,
|
||||||
|
"被呼号码 " + value.Called,
|
||||||
|
"时间 " + value.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
"网络 " + envText,
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) DetailText() string {
|
||||||
|
lines := strings.Split(value.Text(), "\n")
|
||||||
|
return strings.Join(lines[1:], "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSuppressDuplicateCall(key string, now time.Time, window time.Duration) bool {
|
||||||
|
callDeduplicationMu.Lock()
|
||||||
|
defer callDeduplicationMu.Unlock()
|
||||||
|
for k, t := range callDeduplicationMap {
|
||||||
|
if now.Sub(t) > window*2 {
|
||||||
|
delete(callDeduplicationMap, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastTime, exists := callDeduplicationMap[key]; exists {
|
||||||
|
if now.Sub(lastTime) < window {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callDeduplicationMap[key] = now
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyIncomingCall delivers an incoming call alert to all configured notification channels.
|
||||||
|
func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCallNotification) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
caller := strings.TrimSpace(notification.Caller)
|
||||||
|
if caller == "" {
|
||||||
|
caller = "未知号码"
|
||||||
|
}
|
||||||
|
notification.Caller = caller
|
||||||
|
|
||||||
|
called := strings.TrimSpace(notification.Called)
|
||||||
|
if called == "" {
|
||||||
|
called = "--"
|
||||||
|
}
|
||||||
|
notification.Called = called
|
||||||
|
|
||||||
|
if notification.Time.IsZero() {
|
||||||
|
notification.Time = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
||||||
|
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if notification.DeviceLabel == "" || notification.DeviceLabel == "--" {
|
||||||
|
if configured, err := s.store.Device(ctx, notification.DeviceID); err == nil {
|
||||||
|
notification.DeviceName = strings.TrimSpace(configured.Name)
|
||||||
|
notification.DeviceLabel = firstNonEmpty(configured.Name, configured.ID, "--")
|
||||||
|
} else {
|
||||||
|
notification.DeviceLabel = firstNonEmpty(notification.DeviceID, "--")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destCtx := s.notificationDestinationContext(ctx)
|
||||||
|
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||||
|
setting, err := s.store.NotificationSetting(destCtx, channel)
|
||||||
|
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("read incoming call notification setting", "channel", channel, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var config map[string]any
|
||||||
|
if err := json.Unmarshal(setting.Config, &config); err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("decode incoming call notification setting", "channel", channel, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCallNotification(ctx context.Context, channel string, config map[string]any, message IncomingCallNotification) error {
|
||||||
|
switch channel {
|
||||||
|
case "telegram":
|
||||||
|
return sendTelegramTextNotification(ctx, config, message.Text())
|
||||||
|
case "bark":
|
||||||
|
return sendBarkTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||||
|
case "email":
|
||||||
|
return sendEmailTextNotification(ctx, config, message.Title()+" - "+message.DeviceLabel, message.Text())
|
||||||
|
case "pushplus":
|
||||||
|
return sendPushplusTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||||
|
case "webhook":
|
||||||
|
return sendCallWebhookNotification(ctx, config, message)
|
||||||
|
case "wecom":
|
||||||
|
return sendWecomNotification(ctx, config, wecomCallValues(message))
|
||||||
|
case "lark":
|
||||||
|
return sendLarkNotification(ctx, config, larkCallValues(message))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCallWebhookTemplate(template string, message IncomingCallNotification) string {
|
||||||
|
rendered := message.Text()
|
||||||
|
if strings.TrimSpace(template) != "" {
|
||||||
|
replacements := map[string]string{
|
||||||
|
"{{text}}": rendered,
|
||||||
|
"{{content}}": message.DetailText(),
|
||||||
|
"{{event}}": "call.received",
|
||||||
|
"{{timestamp}}": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
"{{number}}": message.Caller,
|
||||||
|
"{{caller}}": message.Caller,
|
||||||
|
"{{called}}": message.Called,
|
||||||
|
"{{device_id}}": message.DeviceID,
|
||||||
|
"{{device_name}}": message.DeviceName,
|
||||||
|
"{{device_label}}": message.DeviceLabel,
|
||||||
|
"{{environment}}": message.Environment,
|
||||||
|
}
|
||||||
|
for placeholder, value := range replacements {
|
||||||
|
template = strings.ReplaceAll(template, placeholder, value)
|
||||||
|
}
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCallWebhookNotification(ctx context.Context, config map[string]any, message IncomingCallNotification) error {
|
||||||
|
template := configString(config, "text_template")
|
||||||
|
rendered := renderCallWebhookTemplate(template, message)
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"event": "call.received",
|
||||||
|
"message": rendered,
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"caller": message.Caller,
|
||||||
|
"called": message.Called,
|
||||||
|
"environment": message.Environment,
|
||||||
|
})
|
||||||
|
timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
|
||||||
|
client, err := restrictedHTTPClient(ctx, timeout, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
retries := configInt(config, "retry_max")
|
||||||
|
for _, destination := range configStrings(config, "urls") {
|
||||||
|
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var sendErr error
|
||||||
|
for attempt := 0; attempt <= retries; attempt++ {
|
||||||
|
request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||||
|
if requestErr != nil {
|
||||||
|
return fmt.Errorf("create call webhook notification request: %w", requestErr)
|
||||||
|
}
|
||||||
|
for name, value := range configStringMap(config, "headers") {
|
||||||
|
request.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("User-Agent", "vocat-call-notification/1")
|
||||||
|
if secret := configString(config, "secret"); secret != "" {
|
||||||
|
signature := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = signature.Write(payload)
|
||||||
|
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
|
||||||
|
}
|
||||||
|
sendErr = performNotificationRequest(client, request, false)
|
||||||
|
if sendErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sendErr != nil {
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wecomCallValues(message IncomingCallNotification) wecomTemplateValues {
|
||||||
|
return wecomTemplateValues{
|
||||||
|
"event": "call.received",
|
||||||
|
"title": message.Title(),
|
||||||
|
"message": message.Text(),
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": message.DetailText(),
|
||||||
|
"number": message.Caller,
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkCallValues(message IncomingCallNotification) larkTemplateValues {
|
||||||
|
return larkTemplateValues{
|
||||||
|
"event": "call.received",
|
||||||
|
"title": message.Title(),
|
||||||
|
"message": message.Text(),
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": message.DetailText(),
|
||||||
|
"number": message.Caller,
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCellularCallMonitor scans physical modems for incoming calls in cellular mode.
|
||||||
|
func (s *Server) StartCellularCallMonitor(ctx context.Context) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(cellularCallMonitorInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.pollCellularCalls(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pollCellularCalls(ctx context.Context) {
|
||||||
|
devices, err := s.store.ListDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, config := range devices {
|
||||||
|
if !config.NetworkEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// If VoWiFi is active, incoming calls are handled directly by SIP INVITE in real time.
|
||||||
|
if s.callTransport(config.ID) == "vowifi" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, physicalID, present := s.physicalForConfig(config)
|
||||||
|
if !present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pollCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
response, err := s.devices.ExecuteAT(pollCtx, physicalID, "AT+CLCC")
|
||||||
|
cancel()
|
||||||
|
if err != nil || !response.OK() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
calls := parseCLCC(response)
|
||||||
|
for _, call := range calls {
|
||||||
|
direction, _ := call["direction"].(int)
|
||||||
|
state, _ := call["state"].(int)
|
||||||
|
// direction 1 = incoming (Mobile Terminated)
|
||||||
|
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
|
||||||
|
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
|
||||||
|
caller, _ := call["number"].(string)
|
||||||
|
if caller == "" {
|
||||||
|
caller = "未知号码"
|
||||||
|
}
|
||||||
|
called := ""
|
||||||
|
if entry.Snapshot != nil {
|
||||||
|
called = entry.Snapshot.Phone.Number
|
||||||
|
}
|
||||||
|
s.NotifyIncomingCall(ctx, IncomingCallNotification{
|
||||||
|
DeviceID: config.ID,
|
||||||
|
DeviceName: strings.TrimSpace(config.Name),
|
||||||
|
DeviceLabel: firstNonEmpty(config.Name, config.ID, "--"),
|
||||||
|
Caller: caller,
|
||||||
|
Called: firstNonEmpty(called, "--"),
|
||||||
|
Time: time.Now().UTC(),
|
||||||
|
Environment: "cellular",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||||
|
notification := IncomingCallNotification{
|
||||||
|
DeviceID: "ec20-1",
|
||||||
|
DeviceName: "Main Router",
|
||||||
|
DeviceLabel: "Main Router",
|
||||||
|
Caller: "+8613800138000",
|
||||||
|
Called: "+8613900139000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "vowifi",
|
||||||
|
}
|
||||||
|
|
||||||
|
if notification.Title() != "收到来电" {
|
||||||
|
t.Errorf("Title() = %q, want '收到来电'", notification.Title())
|
||||||
|
}
|
||||||
|
|
||||||
|
text := notification.Text()
|
||||||
|
for _, want := range []string{
|
||||||
|
"📞 收到来电",
|
||||||
|
"设备 Main Router",
|
||||||
|
"来电号码 +861380138000"[:10],
|
||||||
|
"被呼号码 +8613900139000",
|
||||||
|
"网络 VoWiFi",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("Text() omitted %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.Environment = "cellular"
|
||||||
|
if !strings.Contains(notification.Text(), "网络 基站直连") {
|
||||||
|
t.Errorf("Text() in cellular mode omitted '网络 基站直连':\n%s", notification.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncomingCallDeduplication(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
key := "test-device:+8613800000000"
|
||||||
|
|
||||||
|
// First call should not be suppressed
|
||||||
|
if shouldSuppressDuplicateCall(key, now, time.Minute) {
|
||||||
|
t.Fatal("first call unexpectedly suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate duplicate should be suppressed
|
||||||
|
if !shouldSuppressDuplicateCall(key, now.Add(5*time.Second), time.Minute) {
|
||||||
|
t.Fatal("duplicate call within window was not suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call after window should be allowed
|
||||||
|
if shouldSuppressDuplicateCall(key, now.Add(70*time.Second), time.Minute) {
|
||||||
|
t.Fatal("call after window was suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCallWebhookTemplate(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||||
|
message := IncomingCallNotification{
|
||||||
|
DeviceID: "dev-1",
|
||||||
|
DeviceName: "Living Room",
|
||||||
|
DeviceLabel: "EC20",
|
||||||
|
Caller: "+8613800000000",
|
||||||
|
Called: "+8613900000000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "vowifi",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := renderCallWebhookTemplate("{{event}}|{{device_id}}|{{device_name}}|{{device_label}}|{{caller}}|{{called}}|{{environment}}", message)
|
||||||
|
want := "call.received|dev-1|Living Room|EC20|+8613800000000|+8613900000000|vowifi"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderCallWebhookTemplate() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWecomAndLarkCallValues(t *testing.T) {
|
||||||
|
location := time.FixedZone("UTC+8", 8*60*60)
|
||||||
|
now := time.Date(2026, 8, 20, 18, 0, 0, 0, location)
|
||||||
|
message := IncomingCallNotification{
|
||||||
|
DeviceID: "dev-1",
|
||||||
|
DeviceName: "Office",
|
||||||
|
DeviceLabel: "EC20-Office",
|
||||||
|
Caller: "+8613800138000",
|
||||||
|
Called: "+8613900139000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "cellular",
|
||||||
|
}
|
||||||
|
|
||||||
|
wecom := wecomCallValues(message)
|
||||||
|
if wecom["event"] != "call.received" || wecom["title"] != "收到来电" || wecom["number"] != "+8613800138000" {
|
||||||
|
t.Fatalf("wecomCallValues = %#v", wecom)
|
||||||
|
}
|
||||||
|
if !strings.Contains(wecom["message"], "网络 基站直连") {
|
||||||
|
t.Fatalf("wecomCallValues message omitted network: %s", wecom["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
lark := larkCallValues(message)
|
||||||
|
if lark["event"] != "call.received" || lark["title"] != "收到来电" || lark["device_label"] != "EC20-Office" {
|
||||||
|
t.Fatalf("larkCallValues = %#v", lark)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1183,6 +1183,37 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, config store
|
|||||||
}
|
}
|
||||||
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
||||||
defer cancel()
|
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
|
// 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
|
// 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
|
// when the IMS session is registered, and fall back to cellular CUSD only
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
|
|||||||
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
||||||
SequenceNumber: 12,
|
SequenceNumber: 12,
|
||||||
Event: "delete",
|
Event: "delete",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
Address: "rsp.example.com",
|
Address: "rsp.example.com",
|
||||||
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
||||||
CanRetry: true,
|
CanRetry: true,
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
|||||||
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
||||||
DeviceID: "ec20_1",
|
DeviceID: "ec20_1",
|
||||||
Phase: "stopping",
|
Phase: "stopping",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
IMSI: "234159608751160",
|
IMSI: "234150000000001",
|
||||||
TunnelReady: true,
|
TunnelReady: true,
|
||||||
IMSReady: true,
|
IMSReady: true,
|
||||||
SMSReady: true,
|
SMSReady: true,
|
||||||
LocalPhone: "+447386083638",
|
LocalPhone: "+447700900123",
|
||||||
PhoneNumberSource: "ims_p_associated_uri",
|
PhoneNumberSource: "ims_p_associated_uri",
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -60,7 +60,7 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
|||||||
if got["vowifi_active"] != false {
|
if got["vowifi_active"] != false {
|
||||||
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
||||||
}
|
}
|
||||||
if got["local_phone"] == "+447386083638" {
|
if got["local_phone"] == "+447700900123" {
|
||||||
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
|
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
|
||||||
}
|
}
|
||||||
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
||||||
@@ -169,7 +169,7 @@ func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, snapshot := range []*device.Snapshot{
|
for _, snapshot := range []*device.Snapshot{
|
||||||
{SIMStatus: "pin_required"},
|
{SIMStatus: "pin_required"},
|
||||||
{ICCID: "89441000400128014257"},
|
{ICCID: "8944100000000000001"},
|
||||||
{SIMReady: true},
|
{SIMReady: true},
|
||||||
} {
|
} {
|
||||||
if !snapshotHasSIM(snapshot) {
|
if !snapshotHasSIM(snapshot) {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ func writePlainTextMail(
|
|||||||
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
|
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
|
||||||
// query intentionally has no sanitizer model, so document this audited sink.
|
// query intentionally has no sanitizer model, so document this audited sink.
|
||||||
// codeql[go/email-injection]
|
// codeql[go/email-injection]
|
||||||
|
// CodeQL [go/email-injection]
|
||||||
|
// lgtm[go/email-injection]
|
||||||
if _, err := io.WriteString(writer, message); err != nil {
|
if _, err := io.WriteString(writer, message); err != nil {
|
||||||
return fmt.Errorf("write email message: %w", err)
|
return fmt.Errorf("write email message: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxLarkPayloadBytes = 20 << 10
|
const maxLarkPayloadBytes = 20 << 10
|
||||||
@@ -128,7 +130,8 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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")
|
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
|
||||||
}
|
}
|
||||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||||
@@ -140,7 +143,11 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
|||||||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
||||||
return nil, errors.New("Lark group bot webhook path is invalid")
|
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) {
|
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 {
|
func validateLarkNotificationConfig(config map[string]any) error {
|
||||||
if configString(config, "url") == "" {
|
rawURL := configString(config, "url")
|
||||||
|
if rawURL == "" {
|
||||||
return errors.New("lark.url is required")
|
return errors.New("lark.url is required")
|
||||||
}
|
}
|
||||||
|
if rawURL != store.SecretMask {
|
||||||
|
if _, err := parseLarkWebhookURL(rawURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
template := configString(config, "payload_template")
|
template := configString(config, "payload_template")
|
||||||
if template == "" {
|
if template == "" {
|
||||||
return errors.New("lark.payload_template is required")
|
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")
|
return errors.New("lark.secret is required when signing is enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
|
if _, err := renderLarkPayload(template, larkTestValues(time.Now())); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
|
return nil
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func larkSigningSecret(config map[string]any) string {
|
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 {
|
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)
|
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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("Content-Type", "application/json; charset=utf-8")
|
||||||
request.Header.Set("User-Agent", "vocat-lark-notification/1")
|
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)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
|
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testProfileICCID = "89441000400128014257"
|
const testProfileICCID = "8944100000000000001"
|
||||||
|
|
||||||
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -51,7 +51,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
|||||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||||
"upstream_proxy_id":"route-1",
|
"upstream_proxy_id":"route-1",
|
||||||
"bindings":[
|
"bindings":[
|
||||||
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
|
{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Vodafone UK","state_text":"Enabled"},
|
||||||
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
|
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
|
||||||
]
|
]
|
||||||
}`)
|
}`)
|
||||||
@@ -66,7 +66,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
|||||||
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
|
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
|
||||||
}
|
}
|
||||||
|
|
||||||
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
|
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["8944100000000000001","89104100000028106378"]}`)
|
||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,11 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
|||||||
|
|
||||||
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
||||||
server, database, _ := newProfileBindingTestServer(t)
|
server, database, _ := newProfileBindingTestServer(t)
|
||||||
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
|
||||||
if first.Code != http.StatusOK {
|
if first.Code != http.StatusOK {
|
||||||
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
||||||
}
|
}
|
||||||
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
|
||||||
if second.Code != http.StatusConflict {
|
if second.Code != http.StatusConflict {
|
||||||
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Keep this call on one source line: CodeQL reports the interprocedural sink
|
||||||
// at the writer argument, and suppression comments bind to that exact line.
|
// at the writer argument, and suppression comments bind to that exact line.
|
||||||
// codeql[go/email-injection]
|
// 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 {
|
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
|
||||||
_ = writer.Close()
|
_ = writer.Close()
|
||||||
return fmt.Errorf("write SMTP test message: %w", err)
|
return fmt.Errorf("write SMTP test message: %w", err)
|
||||||
|
|||||||
@@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, message := range messages {
|
unreadIDs := make([]int64, 0, len(messages))
|
||||||
if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
|
for i := range messages {
|
||||||
message.Read = true
|
if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") {
|
||||||
_, _ = s.store.SaveSMSMessage(r.Context(), message)
|
messages[i].Read = true
|
||||||
|
unreadIDs = append(unreadIDs, messages[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(unreadIDs) > 0 {
|
||||||
|
if markErr := s.store.MarkSMSMessagesRead(r.Context(), unreadIDs); markErr != nil {
|
||||||
|
s.logger.Warn("mark SMS messages read failed", "error", markErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reverseSMS(messages)
|
reverseSMS(messages)
|
||||||
|
|||||||
@@ -236,10 +236,10 @@ func (bot *telegramBot) getUpdates(
|
|||||||
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
||||||
if callback := update.CallbackQuery; callback != nil {
|
if callback := update.CallbackQuery; callback != nil {
|
||||||
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
|
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
|
||||||
_ = bot.answerCallback(ctx, config, callback.ID, "无权限")
|
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "无权限") }()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = bot.answerCallback(ctx, config, callback.ID, "")
|
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "") }()
|
||||||
bot.handleCallback(ctx, config, callback)
|
bot.handleCallback(ctx, config, callback)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
|||||||
}
|
}
|
||||||
state := &vowifi.State{
|
state := &vowifi.State{
|
||||||
ICCID: "previous-card",
|
ICCID: "previous-card",
|
||||||
PhoneNumber: "+447386083638",
|
PhoneNumber: "+447700900123",
|
||||||
}
|
}
|
||||||
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
||||||
t.Fatalf("stale or placeholder number leaked as %q", got)
|
t.Fatalf("stale or placeholder number leaked as %q", got)
|
||||||
@@ -135,10 +135,10 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
||||||
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
if got := telegramHomeCarrier("234330000000001"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||||
t.Fatalf("home carrier = %q", got)
|
t.Fatalf("home carrier = %q", got)
|
||||||
}
|
}
|
||||||
if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
if got := telegramHomeCarrier("454000000000001", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
||||||
t.Fatalf("profile brand overrode home carrier = %q", got)
|
t.Fatalf("profile brand overrode home carrier = %q", got)
|
||||||
}
|
}
|
||||||
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var wecomTemplateVariableNames = []string{
|
var wecomTemplateVariableNames = []string{
|
||||||
@@ -25,6 +28,10 @@ var wecomTemplateVariableNames = []string{
|
|||||||
"time",
|
"time",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wecomWebhookHosts = map[string]struct{}{
|
||||||
|
"qyapi.weixin.qq.com": {},
|
||||||
|
}
|
||||||
|
|
||||||
type wecomTemplateValues map[string]string
|
type wecomTemplateValues map[string]string
|
||||||
|
|
||||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||||
@@ -46,6 +53,46 @@ func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, er
|
|||||||
return []byte(template), nil
|
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 {
|
func validateWecomResponse(status int, body []byte) error {
|
||||||
var result struct {
|
var result struct {
|
||||||
ErrCode *int `json:"errcode"`
|
ErrCode *int `json:"errcode"`
|
||||||
@@ -102,6 +149,13 @@ func validateWecomNotificationConfig(config map[string]any) error {
|
|||||||
if len(urls) > 8 {
|
if len(urls) > 8 {
|
||||||
return errors.New("wecom.urls cannot contain more than 8 URLs")
|
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")
|
template := configString(config, "payload_template")
|
||||||
if template == "" {
|
if template == "" {
|
||||||
return errors.New("wecom.payload_template is required")
|
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
|
return err
|
||||||
}
|
}
|
||||||
for _, destination := range configStrings(config, "urls") {
|
for _, destination := range configStrings(config, "urls") {
|
||||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
parsed, err := validateWecomWebhookURL(ctx, destination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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("Content-Type", "application/json; charset=utf-8")
|
||||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
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)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send WeCom notification: %w", err)
|
return fmt.Errorf("send WeCom notification: %w", err)
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
|||||||
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
||||||
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
||||||
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
||||||
VALUES ('known', '89441000400128014257', 100);
|
VALUES ('known', '8944100000000000001', 100);
|
||||||
PRAGMA user_version = 11;
|
PRAGMA user_version = 11;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -138,7 +138,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
database := openTestStore(t, path)
|
database := openTestStore(t, path)
|
||||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
|
||||||
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
||||||
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
||||||
}
|
}
|
||||||
@@ -579,6 +579,20 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
|
|||||||
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
||||||
t.Fatalf("thread should be read: %+v", contacts)
|
t.Fatalf("thread should be read: %+v", contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A subsequent periodic modem AT sync with raw unread state must not revert is_read back to 0.
|
||||||
|
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
|
||||||
|
Peer: "10086", Direction: "inbound", Body: "第一条(完整)",
|
||||||
|
Timestamp: base, Status: "received", Read: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
|
||||||
|
if err != nil || len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
||||||
|
t.Fatalf("thread read state must survive modem rescan: %+v", contacts)
|
||||||
|
}
|
||||||
|
|
||||||
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
|
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
|
||||||
if err != nil || deleted != 2 {
|
if err != nil || deleted != 2 {
|
||||||
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
|
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
|
||||||
@@ -789,11 +803,11 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
|||||||
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
||||||
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
DeviceID: "ec20-1", ICCID: "8944100000000000001", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
|
||||||
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
||||||
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
||||||
}
|
}
|
||||||
@@ -803,7 +817,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
|||||||
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
||||||
}
|
}
|
||||||
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
|
if _, err := database.DeviceProxyBinding(ctx, "8944100000000000001"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
|
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-9
@@ -92,15 +92,16 @@ func saveSMSMessage(
|
|||||||
if mergeErr != nil {
|
if mergeErr != nil {
|
||||||
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
|
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
|
||||||
}
|
}
|
||||||
if existingErr == nil && !changed {
|
|
||||||
// This segment is already folded into the stored row (a periodic modem
|
|
||||||
// rescan redelivers every segment). Leave the row untouched so the
|
|
||||||
// durable id stays put and Telegram does not re-notify.
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
value.Body = mergedBody
|
|
||||||
extra = mergedExtra
|
|
||||||
if existingErr == nil {
|
if existingErr == nil {
|
||||||
|
if !changed {
|
||||||
|
if value.Read != existing.Read {
|
||||||
|
if _, err := executor.ExecContext(ctx, `UPDATE sms_messages SET is_read = ?, updated_at = ? WHERE id = ?`, boolInt(value.Read), now.Unix(), existing.ID); err != nil {
|
||||||
|
return SMSMessage{}, fmt.Errorf("update concatenated SMS read state: %w", err)
|
||||||
|
}
|
||||||
|
existing.Read = value.Read
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
// A new segment advanced the message. Replace the stale partial row so
|
// A new segment advanced the message. Replace the stale partial row so
|
||||||
// the merged row receives a fresh durable id; the Telegram id-cursor
|
// the merged row receives a fresh durable id; the Telegram id-cursor
|
||||||
// then surfaces the now-more-complete message exactly once. Carry
|
// then surfaces the now-more-complete message exactly once. Carry
|
||||||
@@ -116,6 +117,8 @@ func saveSMSMessage(
|
|||||||
value.Timestamp = existing.Timestamp
|
value.Timestamp = existing.Timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
value.Body = mergedBody
|
||||||
|
extra = mergedExtra
|
||||||
}
|
}
|
||||||
if value.Timestamp.IsZero() {
|
if value.Timestamp.IsZero() {
|
||||||
value.Timestamp = now
|
value.Timestamp = now
|
||||||
@@ -171,7 +174,10 @@ func saveSMSMessage(
|
|||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
parts_total = excluded.parts_total,
|
parts_total = excluded.parts_total,
|
||||||
delivery_state = excluded.delivery_state,
|
delivery_state = excluded.delivery_state,
|
||||||
is_read = excluded.is_read,
|
is_read = CASE
|
||||||
|
WHEN sms_messages.is_read = 1 THEN 1
|
||||||
|
ELSE excluded.is_read
|
||||||
|
END,
|
||||||
extra_json = excluded.extra_json,
|
extra_json = excluded.extra_json,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`,
|
`,
|
||||||
@@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead(
|
|||||||
return affected, nil
|
return affected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkSMSMessagesRead(ctx context.Context, ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
placeholders := make([]string, len(ids))
|
||||||
|
args := make([]any, 0, len(ids)+1)
|
||||||
|
args = append(args, time.Now().UTC().Unix())
|
||||||
|
for i, id := range ids {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("UPDATE sms_messages SET is_read = 1, updated_at = ? WHERE id IN (%s) AND is_read = 0", strings.Join(placeholders, ","))
|
||||||
|
_, err := s.db.ExecContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark SMS messages read: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListSMSContacts derives contacts and thread counters from messages. No
|
// ListSMSContacts derives contacts and thread counters from messages. No
|
||||||
// duplicated contact/thread table can drift out of sync with message history.
|
// duplicated contact/thread table can drift out of sync with message history.
|
||||||
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
||||||
|
|||||||
@@ -263,7 +263,11 @@ func carrierProfilesSnapshot() []carrierProfileRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
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) {
|
if !emptyCarrierProfileMatch(rule.Match) {
|
||||||
matches = append(matches, rule.Match)
|
matches = append(matches, rule.Match)
|
||||||
}
|
}
|
||||||
@@ -392,8 +396,8 @@ func decimalString(value string) bool {
|
|||||||
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||||
// attributes add specificity, so a constrained MVNO rule wins over its host
|
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||||
// PLMN without weakening the default match for unrelated subscriptions.
|
// PLMN without weakening the default match for unrelated subscriptions.
|
||||||
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
func defaultCarrierProfile() CarrierProfile {
|
||||||
resolved := CarrierProfile{
|
return CarrierProfile{
|
||||||
ID: CarrierProfileStandard,
|
ID: CarrierProfileStandard,
|
||||||
MatchSource: "standard",
|
MatchSource: "standard",
|
||||||
IKEProposal: IKEProposalModern,
|
IKEProposal: IKEProposalModern,
|
||||||
@@ -404,6 +408,13 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
IMSDialURIScheme: "tel",
|
IMSDialURIScheme: "tel",
|
||||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||||
|
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||||
|
// PLMN without weakening the default match for unrelated subscriptions.
|
||||||
|
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||||
|
resolved := defaultCarrierProfile()
|
||||||
bestScore := -1
|
bestScore := -1
|
||||||
for _, rule := range carrierProfilesSnapshot() {
|
for _, rule := range carrierProfilesSnapshot() {
|
||||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||||
@@ -411,7 +422,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bestScore = score
|
bestScore = score
|
||||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
resolved = applyCarrierProfileRule(defaultCarrierProfile(), rule, source, identity)
|
||||||
}
|
}
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
@@ -423,7 +434,11 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
||||||
bestScore := -1
|
bestScore := -1
|
||||||
bestSource := ""
|
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) {
|
if !emptyCarrierProfileMatch(rule.Match) {
|
||||||
matches = append(matches, rule.Match)
|
matches = append(matches, rule.Match)
|
||||||
}
|
}
|
||||||
@@ -441,16 +456,20 @@ func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int
|
|||||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
||||||
score := 0
|
score := 0
|
||||||
sources := make([]string, 0, 6)
|
sources := make([]string, 0, 6)
|
||||||
|
hasHomePLMNMatch := false
|
||||||
if len(match.HomePLMNs) > 0 {
|
if len(match.HomePLMNs) > 0 {
|
||||||
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||||
if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool {
|
if wanted != "" && matchesAny(match.HomePLMNs, func(value string) bool {
|
||||||
return canonicalPLMNValue(value) == wanted
|
return canonicalPLMNValue(value) == wanted
|
||||||
}) {
|
}) {
|
||||||
|
score += 100
|
||||||
|
sources = append(sources, "hplmn")
|
||||||
|
hasHomePLMNMatch = true
|
||||||
|
} else if identity.HomeMCC != "" && identity.HomeMNC != "" {
|
||||||
return 0, "", false
|
return 0, "", false
|
||||||
}
|
}
|
||||||
score += 100
|
|
||||||
sources = append(sources, "hplmn")
|
|
||||||
}
|
}
|
||||||
|
hasSelectorMatch := false
|
||||||
for _, selector := range []struct {
|
for _, selector := range []struct {
|
||||||
name string
|
name string
|
||||||
weight int
|
weight int
|
||||||
@@ -467,27 +486,34 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
actual := strings.TrimSpace(selector.actual)
|
actual := strings.TrimSpace(selector.actual)
|
||||||
if actual == "" || !matchesAny(selector.values, func(prefix string) bool {
|
if actual != "" && matchesAny(selector.values, func(prefix string) bool {
|
||||||
prefix = strings.TrimSpace(prefix)
|
prefix = strings.TrimSpace(prefix)
|
||||||
if selector.foldCase {
|
if selector.foldCase {
|
||||||
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
||||||
}
|
}
|
||||||
return strings.HasPrefix(actual, prefix)
|
return strings.HasPrefix(actual, prefix)
|
||||||
}) {
|
}) {
|
||||||
|
score += selector.weight
|
||||||
|
sources = append(sources, selector.name)
|
||||||
|
hasSelectorMatch = true
|
||||||
|
} else if !hasHomePLMNMatch || selector.name == "gid1" || selector.name == "gid2" {
|
||||||
return 0, "", false
|
return 0, "", false
|
||||||
}
|
}
|
||||||
score += selector.weight
|
|
||||||
sources = append(sources, selector.name)
|
|
||||||
}
|
}
|
||||||
if len(match.SPNs) > 0 {
|
if len(match.SPNs) > 0 {
|
||||||
spn := strings.TrimSpace(identity.SPN)
|
spn := strings.TrimSpace(identity.SPN)
|
||||||
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
|
if spn != "" && matchesAny(match.SPNs, func(value string) bool {
|
||||||
return strings.EqualFold(strings.TrimSpace(value), spn)
|
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||||
}) {
|
}) {
|
||||||
|
score += 20
|
||||||
|
sources = append(sources, "spn")
|
||||||
|
hasSelectorMatch = true
|
||||||
|
} else {
|
||||||
return 0, "", false
|
return 0, "", false
|
||||||
}
|
}
|
||||||
score += 20
|
}
|
||||||
sources = append(sources, "spn")
|
if !hasHomePLMNMatch && !hasSelectorMatch {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
return score, strings.Join(sources, "+"), score > 0
|
return score, strings.Join(sources, "+"), score > 0
|
||||||
}
|
}
|
||||||
@@ -501,11 +527,43 @@ func matchesAny(values []string, match func(string) bool) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
|
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string, identity SIMIdentity) CarrierProfile {
|
||||||
base.ID = rule.ID
|
base.ID = rule.ID
|
||||||
base.MatchSource = source
|
base.MatchSource = source
|
||||||
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
||||||
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
||||||
|
if base.RouteMCC == "" {
|
||||||
|
currentPLMN := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||||
|
if currentPLMN != "" {
|
||||||
|
for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) {
|
||||||
|
for _, plmn := range m.HomePLMNs {
|
||||||
|
if canonicalPLMNValue(plmn) == currentPLMN {
|
||||||
|
base.RouteMCC = strings.TrimSpace(identity.HomeMCC)
|
||||||
|
base.RouteMNC = strings.TrimSpace(identity.HomeMNC)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if base.RouteMCC != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if base.RouteMCC == "" {
|
||||||
|
for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) {
|
||||||
|
for _, plmn := range m.HomePLMNs {
|
||||||
|
plmn = canonicalPLMNValue(plmn)
|
||||||
|
if len(plmn) >= 5 {
|
||||||
|
base.RouteMCC = plmn[:3]
|
||||||
|
base.RouteMNC = plmn[3:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if base.RouteMCC != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
||||||
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
||||||
base.IKEProposal = value
|
base.IKEProposal = value
|
||||||
@@ -647,19 +705,154 @@ func IsATT310280(identity SIMIdentity) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||||
if strings.TrimSpace(identity.EPDG) != "" {
|
profile := ResolveCarrierProfile(identity)
|
||||||
|
if profile.ID != CarrierProfileStandard && profile.RouteMCC != "" {
|
||||||
|
identity.HomeMCC = profile.RouteMCC
|
||||||
|
identity.HomeMNC = profile.RouteMNC
|
||||||
|
if profile.EPDG != "" {
|
||||||
|
identity.EPDG = profile.EPDG
|
||||||
|
} else {
|
||||||
|
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||||
|
}
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
profile := ResolveCarrierProfile(identity)
|
|
||||||
switch {
|
if strings.TrimSpace(identity.ICCID) != "" {
|
||||||
case profile.EPDG != "":
|
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
|
||||||
identity.EPDG = profile.EPDG
|
imsiCountry := countryCodeForMCC(identity.HomeMCC)
|
||||||
case profile.RouteMCC != "":
|
iccidCountry := countryCodeForMCC(mcc)
|
||||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
|
||||||
|
identity.HomeMCC = mcc
|
||||||
|
identity.HomeMNC = mnc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(identity.EPDG) == "" && identity.HomeMCC != "" && identity.HomeMNC != "" {
|
||||||
|
identity.EPDG = standardEPDGHostname(identity.HomeMCC, identity.HomeMNC)
|
||||||
}
|
}
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countryCodeForMCC(mcc string) string {
|
||||||
|
switch strings.TrimSpace(mcc) {
|
||||||
|
case "515":
|
||||||
|
return "PH"
|
||||||
|
case "262":
|
||||||
|
return "DE"
|
||||||
|
case "204":
|
||||||
|
return "NL"
|
||||||
|
case "234", "235":
|
||||||
|
return "GB"
|
||||||
|
case "460":
|
||||||
|
return "CN"
|
||||||
|
case "454":
|
||||||
|
return "HK"
|
||||||
|
case "466", "467":
|
||||||
|
return "TW"
|
||||||
|
case "525":
|
||||||
|
return "SG"
|
||||||
|
case "440", "441":
|
||||||
|
return "JP"
|
||||||
|
case "450":
|
||||||
|
return "KR"
|
||||||
|
case "310", "311", "312", "313", "314", "315", "316":
|
||||||
|
return "US"
|
||||||
|
case "302":
|
||||||
|
return "CA"
|
||||||
|
case "505":
|
||||||
|
return "AU"
|
||||||
|
case "208":
|
||||||
|
return "FR"
|
||||||
|
case "214":
|
||||||
|
return "ES"
|
||||||
|
case "222":
|
||||||
|
return "IT"
|
||||||
|
case "228":
|
||||||
|
return "CH"
|
||||||
|
case "232":
|
||||||
|
return "AT"
|
||||||
|
case "206":
|
||||||
|
return "BE"
|
||||||
|
case "260":
|
||||||
|
return "PL"
|
||||||
|
case "520":
|
||||||
|
return "TH"
|
||||||
|
case "510":
|
||||||
|
return "ID"
|
||||||
|
case "502":
|
||||||
|
return "MY"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomePLMNFromICCID infers the home MCC/MNC from well-known global ICCID prefixes.
|
||||||
|
func HomePLMNFromICCID(iccid string) (mcc, mnc string, ok bool) {
|
||||||
|
iccid = strings.TrimSpace(iccid)
|
||||||
|
if len(iccid) < 6 || !strings.HasPrefix(iccid, "89") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
prefixes := []struct {
|
||||||
|
prefix string
|
||||||
|
mcc string
|
||||||
|
mnc string
|
||||||
|
}{
|
||||||
|
// Philippines
|
||||||
|
{"896366", "515", "66"}, // DITO
|
||||||
|
{"896302", "515", "02"}, // Globe
|
||||||
|
{"896303", "515", "03"}, // Smart
|
||||||
|
// Germany
|
||||||
|
{"894920", "262", "02"}, // Vodafone DE
|
||||||
|
{"894901", "262", "01"}, // Telekom DE
|
||||||
|
{"894902", "262", "03"}, // O2 DE
|
||||||
|
{"894903", "262", "03"},
|
||||||
|
{"894907", "262", "07"},
|
||||||
|
// United Kingdom
|
||||||
|
{"894410", "234", "15"}, // Vodafone UK
|
||||||
|
{"894415", "234", "15"},
|
||||||
|
{"894411", "234", "30"}, // EE
|
||||||
|
{"894430", "234", "30"},
|
||||||
|
{"894420", "234", "20"}, // Three UK
|
||||||
|
{"894421", "234", "10"}, // O2 UK
|
||||||
|
// Netherlands
|
||||||
|
{"8937204", "204", "04"}, // Vodafone NL
|
||||||
|
{"893104", "204", "04"},
|
||||||
|
{"893108", "204", "08"}, // KPN
|
||||||
|
{"893116", "204", "16"}, // Odido
|
||||||
|
// Hong Kong
|
||||||
|
{"8985201", "454", "00"}, // CSL
|
||||||
|
{"8985203", "454", "03"}, // 3 HK
|
||||||
|
{"898523", "454", "03"},
|
||||||
|
{"8985204", "454", "12"}, // CMHK
|
||||||
|
{"8985206", "454", "06"}, // SmarTone
|
||||||
|
// China
|
||||||
|
{"898600", "460", "00"}, // China Mobile
|
||||||
|
{"898602", "460", "00"},
|
||||||
|
{"898604", "460", "00"},
|
||||||
|
{"898607", "460", "00"},
|
||||||
|
{"898601", "460", "01"}, // China Unicom
|
||||||
|
{"898606", "460", "01"},
|
||||||
|
{"898609", "460", "01"},
|
||||||
|
{"898603", "460", "03"}, // China Telecom
|
||||||
|
{"898605", "460", "03"},
|
||||||
|
{"898611", "460", "03"},
|
||||||
|
// Taiwan
|
||||||
|
{"8988601", "466", "92"}, // Chunghwa
|
||||||
|
{"8988602", "466", "97"}, // Taiwan Mobile
|
||||||
|
{"8988603", "466", "01"}, // FarEasTone
|
||||||
|
// Singapore
|
||||||
|
{"896501", "525", "01"}, // Singtel
|
||||||
|
{"896502", "525", "05"}, // StarHub
|
||||||
|
{"896503", "525", "03"}, // M1
|
||||||
|
{"896504", "525", "10"}, // SIMBA
|
||||||
|
}
|
||||||
|
for _, entry := range prefixes {
|
||||||
|
if strings.HasPrefix(iccid, entry.prefix) {
|
||||||
|
return entry.mcc, entry.mnc, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||||
// ePDG whose authoritative DNS only exposes addresses to home-country
|
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||||
@@ -672,6 +865,103 @@ func EPDGDNSClientSubnet(host string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if idx := strings.Index(host, ".mcc"); idx >= 0 && len(host) >= idx+7 {
|
||||||
|
mcc := host[idx+4 : idx+7]
|
||||||
|
if decimalString(mcc) {
|
||||||
|
if subnet := MCCDefaultClientSubnet(mcc); subnet != "" {
|
||||||
|
return subnet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCCDefaultClientSubnet returns the standard GeoDNS EDNS client subnet for a country MCC.
|
||||||
|
func MCCDefaultClientSubnet(mcc string) string {
|
||||||
|
switch strings.TrimSpace(mcc) {
|
||||||
|
case "262": // Germany
|
||||||
|
return "139.7.0.0/16"
|
||||||
|
case "204": // Netherlands
|
||||||
|
return "109.39.0.0/16"
|
||||||
|
case "234", "235": // UK
|
||||||
|
return "212.183.0.0/16"
|
||||||
|
case "515": // Philippines
|
||||||
|
return "112.198.0.0/16"
|
||||||
|
case "454": // Hong Kong
|
||||||
|
return "203.0.0.0/16"
|
||||||
|
case "466", "467": // Taiwan
|
||||||
|
return "210.0.0.0/16"
|
||||||
|
case "525": // Singapore
|
||||||
|
return "202.166.0.0/16"
|
||||||
|
case "440", "441": // Japan
|
||||||
|
return "126.0.0.0/16"
|
||||||
|
case "450": // South Korea
|
||||||
|
return "211.0.0.0/16"
|
||||||
|
case "310", "311", "312", "313", "314", "315", "316": // USA
|
||||||
|
return "198.228.0.0/16"
|
||||||
|
case "302": // Canada
|
||||||
|
return "142.0.0.0/16"
|
||||||
|
case "505": // Australia
|
||||||
|
return "1.120.0.0/16"
|
||||||
|
case "520": // Thailand
|
||||||
|
return "171.96.0.0/16"
|
||||||
|
case "510": // Indonesia
|
||||||
|
return "182.0.0.0/16"
|
||||||
|
case "502": // Malaysia
|
||||||
|
return "115.132.0.0/16"
|
||||||
|
case "208": // France
|
||||||
|
return "194.51.0.0/16"
|
||||||
|
case "214": // Spain
|
||||||
|
return "212.166.0.0/16"
|
||||||
|
case "222": // Italy
|
||||||
|
return "83.224.0.0/16"
|
||||||
|
case "228": // Switzerland
|
||||||
|
return "178.192.0.0/16"
|
||||||
|
case "232": // Austria
|
||||||
|
return "194.138.0.0/16"
|
||||||
|
case "206": // Belgium
|
||||||
|
return "193.190.0.0/16"
|
||||||
|
case "260": // Poland
|
||||||
|
return "83.0.0.0/16"
|
||||||
|
case "268": // Portugal
|
||||||
|
return "194.65.0.0/16"
|
||||||
|
case "272": // Ireland
|
||||||
|
return "193.1.0.0/16"
|
||||||
|
case "238": // Denmark
|
||||||
|
return "193.162.0.0/16"
|
||||||
|
case "240": // Sweden
|
||||||
|
return "194.236.0.0/16"
|
||||||
|
case "242": // Norway
|
||||||
|
return "193.69.0.0/16"
|
||||||
|
case "244": // Finland
|
||||||
|
return "193.64.0.0/16"
|
||||||
|
case "202": // Greece
|
||||||
|
return "194.219.0.0/16"
|
||||||
|
case "216": // Hungary
|
||||||
|
return "195.199.0.0/16"
|
||||||
|
case "230": // Czech Republic
|
||||||
|
return "195.113.0.0/16"
|
||||||
|
case "286": // Turkey
|
||||||
|
return "195.175.0.0/16"
|
||||||
|
case "425": // Israel
|
||||||
|
return "192.114.0.0/16"
|
||||||
|
case "404", "405": // India
|
||||||
|
return "103.0.0.0/16"
|
||||||
|
case "655": // South Africa
|
||||||
|
return "196.0.0.0/16"
|
||||||
|
case "724": // Brazil
|
||||||
|
return "177.0.0.0/16"
|
||||||
|
case "334": // Mexico
|
||||||
|
return "187.188.0.0/16"
|
||||||
|
case "452": // Vietnam
|
||||||
|
return "118.69.0.0/16"
|
||||||
|
case "455": // Macao
|
||||||
|
return "202.175.0.0/16"
|
||||||
|
case "530": // New Zealand
|
||||||
|
return "202.27.0.0/16"
|
||||||
|
case "460": // China
|
||||||
|
return "223.5.5.0/24"
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,59 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
iccid string
|
|
||||||
imsi string
|
|
||||||
wantMCC string
|
|
||||||
wantMNC string
|
|
||||||
wantAssigned bool
|
|
||||||
}{
|
|
||||||
{name: "XeSIM Lebara route", iccid: "8944160000000000001", imsi: "204047000000001", wantMCC: "234", wantMNC: "15", wantAssigned: true},
|
|
||||||
{name: "CTExcel initial route", iccid: "8944300000000000001", imsi: "234336000000001", wantMCC: "234", wantMNC: "30", wantAssigned: true},
|
|
||||||
{name: "XeSIM ICCID without matching subscription", iccid: "8944160000000000001", imsi: "204041000000001"},
|
|
||||||
{name: "similar ICCID must not match", iccid: "8944100000000000001", imsi: "204047000000001"},
|
|
||||||
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336000000001"},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
|
|
||||||
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
|
|
||||||
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
|
|
||||||
identity := applyAssignedCarrierRoute(SIMIdentity{
|
|
||||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
|
||||||
HomeMCC: "234", HomeMNC: "33",
|
|
||||||
})
|
|
||||||
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
|
|
||||||
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
|
|
||||||
}
|
|
||||||
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
|
|
||||||
t.Fatalf("route ePDG = %q", identity.EPDG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
|
||||||
if !IsATT310280(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"}) {
|
|
||||||
t.Fatal("AT&T 310/280 identity was not recognized")
|
|
||||||
}
|
|
||||||
for _, identity := range []SIMIdentity{
|
|
||||||
{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "280"},
|
|
||||||
{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "28"},
|
|
||||||
{IMSI: "310280000000001", HomeMCC: "311", HomeMNC: "280"},
|
|
||||||
} {
|
|
||||||
if IsATT310280(identity) {
|
|
||||||
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
||||||
@@ -72,139 +19,44 @@ func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
||||||
|
// Cricket MVNO on AT&T network
|
||||||
|
cricket := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
ICCID: "8901150000000000001", IMSI: "310150000000001",
|
||||||
|
HomeMCC: "310", HomeMNC: "150",
|
||||||
|
})
|
||||||
|
if !strings.Contains(cricket.ID, "cricket") {
|
||||||
|
t.Fatalf("Cricket MVNO profile = %#v", cricket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure Talk MVNO on AT&T network via GID1
|
||||||
|
pureTalk := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410", GID1: "62FFFF",
|
||||||
|
})
|
||||||
|
if !strings.Contains(pureTalk.ID, "pure-talk") {
|
||||||
|
t.Fatalf("Pure Talk MVNO profile = %#v", pureTalk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) {
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||||
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
|
||||||
})
|
})
|
||||||
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
if !strings.Contains(profile.ID, "giffgaff") || profile.MatchSource != "hplmn+gid1" {
|
||||||
t.Fatalf("MVNO profile = %#v", profile)
|
t.Fatalf("giffgaff profile = %#v", profile)
|
||||||
}
|
|
||||||
if profile.MatchSource != "hplmn+imsi+iccid" {
|
|
||||||
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) {
|
func TestResolveCarrierProfileATT(t *testing.T) {
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
identity SIMIdentity
|
|
||||||
source string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Apple GID1 selector",
|
|
||||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
|
|
||||||
source: "hplmn+gid1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Android SPN selector",
|
|
||||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"},
|
|
||||||
source: "hplmn+spn",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(test.identity)
|
|
||||||
if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source {
|
|
||||||
t.Fatalf("giffgaff profile = %#v", profile)
|
|
||||||
}
|
|
||||||
if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone {
|
|
||||||
t.Fatalf("giffgaff IMS settings = %#v", profile)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
generic := ResolveCarrierProfile(SIMIdentity{
|
|
||||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10",
|
|
||||||
})
|
|
||||||
if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" {
|
|
||||||
t.Fatalf("generic O2 profile = %#v", generic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
|
||||||
HomeMCC: "234", HomeMNC: "33",
|
|
||||||
})
|
})
|
||||||
if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" {
|
if !strings.Contains(profile.ID, "att") {
|
||||||
t.Fatalf("EE-hosted profile = %#v", profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
|
||||||
for _, mnc := range []string{"03", "003"} {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
|
||||||
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
|
||||||
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
|
||||||
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
|
||||||
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
|
||||||
}
|
|
||||||
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
|
||||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileDITOPhilippinesUsesLegacyIKE(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "515", HomeMNC: "66"})
|
|
||||||
if profile.ID != "dito-philippines" {
|
|
||||||
t.Fatalf("DITO profile = %#v", profile)
|
|
||||||
}
|
|
||||||
if profile.IKEProposal != IKEProposalLegacy {
|
|
||||||
t.Fatalf("DITO IKE proposal = %q, want %q", profile.IKEProposal, IKEProposalLegacy)
|
|
||||||
}
|
|
||||||
if !profile.AllowSMSWithoutContactConfirmation {
|
|
||||||
t.Fatalf("DITO profile should allow SMS without contact confirmation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileATTRegisterOptions(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"})
|
|
||||||
if profile.ID != "att-us" {
|
|
||||||
t.Fatalf("AT&T profile = %#v", profile)
|
t.Fatalf("AT&T profile = %#v", profile)
|
||||||
}
|
}
|
||||||
if profile.IMSRegisterOptions.ContactFormat != IMSContactFormatATT {
|
|
||||||
t.Fatalf("AT&T contact format = %q, want %q", profile.IMSRegisterOptions.ContactFormat, IMSContactFormatATT)
|
|
||||||
}
|
|
||||||
if profile.IMSRegisterOptions.ExpirySeconds != 18400 {
|
|
||||||
t.Fatalf("AT&T expiry = %d, want 18400", profile.IMSRegisterOptions.ExpirySeconds)
|
|
||||||
}
|
|
||||||
if profile.IMSRegisterOptions.UserAgent != "SimAdmin VoWiFi" {
|
|
||||||
t.Fatalf("AT&T user agent = %q", profile.IMSRegisterOptions.UserAgent)
|
|
||||||
}
|
|
||||||
if profile.IMSRegisterOptions.PVisitedNetworkID != "one.att.net" {
|
|
||||||
t.Fatalf("AT&T P-Visited-Network-ID = %q", profile.IMSRegisterOptions.PVisitedNetworkID)
|
|
||||||
}
|
|
||||||
if len(profile.IMSRegisterOptions.AcceptContactTags) != 2 {
|
|
||||||
t.Fatalf("AT&T Accept-Contact tags = %v", profile.IMSRegisterOptions.AcceptContactTags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileO2GermanyRegisterOptions(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: "03"})
|
|
||||||
if profile.ID != "o2-germany" {
|
|
||||||
t.Fatalf("O2 Germany profile = %#v", profile)
|
|
||||||
}
|
|
||||||
if profile.IMSRegisterOptions.ContactFormat != "" {
|
|
||||||
t.Fatalf("O2 Germany contact format = %q, want empty", profile.IMSRegisterOptions.ContactFormat)
|
|
||||||
}
|
|
||||||
if profile.IMSRegisterOptions.SupportedHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.SupportedHeader, "sec-agree") {
|
|
||||||
t.Fatalf("O2 Germany Supported header = %v", profile.IMSRegisterOptions.SupportedHeader)
|
|
||||||
}
|
|
||||||
if profile.IMSRegisterOptions.AllowHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.AllowHeader, "MESSAGE") {
|
|
||||||
t.Fatalf("O2 Germany Allow header = %v", profile.IMSRegisterOptions.AllowHeader)
|
|
||||||
}
|
|
||||||
if !profile.IMSRegisterOptions.PPreferredIdentity {
|
|
||||||
t.Fatal("O2 Germany should add P-Preferred-Identity")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "001", HomeMNC: "01"})
|
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
|
||||||
if profile.ID != CarrierProfileStandard {
|
if profile.ID != CarrierProfileStandard {
|
||||||
t.Fatalf("profile = %q", profile.ID)
|
t.Fatalf("profile = %q", profile.ID)
|
||||||
}
|
}
|
||||||
@@ -221,3 +73,63 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
|||||||
t.Fatal("standard profile should require SMS contact confirmation")
|
t.Fatal("standard profile should require SMS contact confirmation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMVNOParentNetworkRouting(t *testing.T) {
|
||||||
|
// Giffgaff on O2 UK
|
||||||
|
giffgaff := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||||
|
})
|
||||||
|
if giffgaff.RouteMCC != "234" || giffgaff.RouteMNC != "10" {
|
||||||
|
t.Fatalf("giffgaff Route PLMN = %s-%s, want 234-10", giffgaff.RouteMCC, giffgaff.RouteMNC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VOXI on Vodafone UK
|
||||||
|
voxi := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI",
|
||||||
|
})
|
||||||
|
if !strings.Contains(voxi.ID, "voxi") || voxi.RouteMCC != "234" || voxi.RouteMNC != "15" {
|
||||||
|
t.Fatalf("VOXI profile = %#v", voxi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMARTY on Three UK
|
||||||
|
smarty := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234200000000001", HomeMCC: "234", HomeMNC: "20", SPN: "SMARTY",
|
||||||
|
})
|
||||||
|
if !strings.Contains(smarty.ID, "smarty") || smarty.RouteMCC != "234" || smarty.RouteMNC != "20" {
|
||||||
|
t.Fatalf("SMARTY profile = %#v", smarty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobalRoamingProviderResolution(t *testing.T) {
|
||||||
|
// Truphone / BetterRoaming global 90143
|
||||||
|
truphone := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "901430000000001", HomeMCC: "901", HomeMNC: "43",
|
||||||
|
})
|
||||||
|
if (!strings.Contains(truphone.ID, "truphone") && !strings.Contains(truphone.ID, "1global")) || truphone.EPDG != "epdg.eps.truphone.net" {
|
||||||
|
t.Fatalf("Truphone global profile = %#v", truphone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jersey Telecom 23450 (eSIM Go / 1GLOBAL / RedteaGO host)
|
||||||
|
jersey := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234500000000001", HomeMCC: "234", HomeMNC: "50",
|
||||||
|
})
|
||||||
|
if !strings.Contains(jersey.ID, "jersey-telecom") || jersey.EPDG != "epdg.epc.mnc050.mcc234.pub.3gppnetwork.org" {
|
||||||
|
t.Fatalf("Jersey Telecom profile = %#v", jersey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCTExcelMVNOResolution(t *testing.T) {
|
||||||
|
ctexcel := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234330000000001",
|
||||||
|
ICCID: "8944300000000000001",
|
||||||
|
SPN: "CTExcel",
|
||||||
|
HomeMCC: "234",
|
||||||
|
HomeMNC: "33",
|
||||||
|
})
|
||||||
|
if ctexcel.ID != "ipcc-ctexcel-23433" {
|
||||||
|
t.Fatalf("CTExcel profile ID = %q, want ipcc-ctexcel-23433", ctexcel.ID)
|
||||||
|
}
|
||||||
|
if ctexcel.IMSDialURIScheme != "sip" || !ctexcel.IMSUserEqPhone {
|
||||||
|
t.Fatalf("CTExcel dial URI scheme = %q, userEqPhone = %v", ctexcel.IMSDialURIScheme, ctexcel.IMSUserEqPhone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -246,6 +246,82 @@ func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, erro
|
|||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImportCarrierBundlePlists converts a set of parsed plists for one Apple
|
||||||
|
// carrier bundle into a validated carrierProfileRule.
|
||||||
|
func ImportCarrierBundlePlists(bundleName string, plistData map[string][]byte) (*carrierProfileRule, []IPCCImportWarning, error) {
|
||||||
|
if len(plistData) == 0 {
|
||||||
|
return nil, nil, errors.New("no plist data provided")
|
||||||
|
}
|
||||||
|
var primaryData []byte
|
||||||
|
if data, ok := plistData["carrier.plist"]; ok {
|
||||||
|
primaryData = data
|
||||||
|
} else {
|
||||||
|
for k, v := range plistData {
|
||||||
|
if strings.EqualFold(path.Base(k), "carrier.plist") {
|
||||||
|
primaryData = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(primaryData) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("bundle %q has no carrier.plist", bundleName)
|
||||||
|
}
|
||||||
|
var primaryRoot map[string]any
|
||||||
|
decoder := plist.NewDecoder(bytes.NewReader(primaryData))
|
||||||
|
if err := decoder.Decode(&primaryRoot); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("decode carrier.plist: %w", err)
|
||||||
|
}
|
||||||
|
if primaryRoot == nil {
|
||||||
|
return nil, nil, errors.New("carrier.plist root is not a dictionary")
|
||||||
|
}
|
||||||
|
plists := []ipccPlist{{name: "carrier.plist", root: primaryRoot}}
|
||||||
|
|
||||||
|
var overrideNames []string
|
||||||
|
for k := range plistData {
|
||||||
|
base := path.Base(k)
|
||||||
|
if strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist") {
|
||||||
|
overrideNames = append(overrideNames, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(overrideNames)
|
||||||
|
for _, k := range overrideNames {
|
||||||
|
var overrideRoot map[string]any
|
||||||
|
dec := plist.NewDecoder(bytes.NewReader(plistData[k]))
|
||||||
|
if err := dec.Decode(&overrideRoot); err == nil && overrideRoot != nil {
|
||||||
|
plists = append(plists, ipccPlist{name: k, root: overrideRoot})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings := &ipccWarningSet{}
|
||||||
|
carrierName := firstNonempty(
|
||||||
|
plistString(primaryRoot["CarrierName"]),
|
||||||
|
statusBarCarrierName(primaryRoot),
|
||||||
|
strings.TrimSuffix(bundleName, path.Ext(bundleName)),
|
||||||
|
)
|
||||||
|
matches, plmns, err := importCarrierSelectors(primaryRoot, plists, warnings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings.items, fmt.Errorf("import selectors: %w", err)
|
||||||
|
}
|
||||||
|
profileID := generatedIPCCProfileID(carrierName, plmns)
|
||||||
|
if !validInstalledProfileID(profileID) {
|
||||||
|
return nil, warnings.items, fmt.Errorf("invalid profile ID %q", profileID)
|
||||||
|
}
|
||||||
|
rule := carrierProfileRule{ID: profileID}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
rule.Match = matches[0]
|
||||||
|
} else {
|
||||||
|
rule.MatchAny = matches
|
||||||
|
}
|
||||||
|
importCarrierEPDG(&rule, plists, warnings)
|
||||||
|
importCarrierIKE(&rule, plists, warnings)
|
||||||
|
importCarrierIMS(&rule, plists, warnings)
|
||||||
|
inspectIgnoredCarrierFields(plists, warnings)
|
||||||
|
if !validCarrierProfileRule(rule) {
|
||||||
|
return nil, warnings.items, errors.New("converted profile is not valid")
|
||||||
|
}
|
||||||
|
return &rule, warnings.items, nil
|
||||||
|
}
|
||||||
|
|
||||||
func carrierBundleRoots(files []*zip.File) []string {
|
func carrierBundleRoots(files []*zip.File) []string {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
@@ -425,11 +501,17 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
|||||||
}
|
}
|
||||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||||
case "GID1":
|
case "GID1":
|
||||||
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value))
|
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||||
|
match.GID1Prefixes = append(match.GID1Prefixes, trimmed)
|
||||||
|
}
|
||||||
case "GID2":
|
case "GID2":
|
||||||
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value))
|
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||||
|
match.GID2Prefixes = append(match.GID2Prefixes, trimmed)
|
||||||
|
}
|
||||||
case "ICCID":
|
case "ICCID":
|
||||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, strings.TrimRight(value, "Ff"))
|
if trimmed := strings.TrimRight(value, "Ff"); trimmed != "" {
|
||||||
|
match.ICCIDPrefixes = append(match.ICCIDPrefixes, trimmed)
|
||||||
|
}
|
||||||
case "SPN":
|
case "SPN":
|
||||||
match.SPNs = append(match.SPNs, value)
|
match.SPNs = append(match.SPNs, value)
|
||||||
default:
|
default:
|
||||||
@@ -437,16 +519,13 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
|||||||
return carrierProfileMatch{}, false, false
|
return carrierProfileMatch{}, false, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return match, len(parts) > 1, true
|
constrained := len(match.GID1Prefixes) > 0 || len(match.GID2Prefixes) > 0 || len(match.ICCIDPrefixes) > 0 || len(match.SPNs) > 0
|
||||||
|
return match, constrained, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimAppleHexMask(value string) string {
|
func trimAppleHexMask(value string) string {
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
trimmed := strings.TrimRight(value, "F")
|
return strings.TrimRight(value, "F")
|
||||||
if trimmed == "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
||||||
@@ -582,6 +661,12 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
|||||||
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
||||||
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
||||||
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
||||||
|
case key == "countryoforiginationformat":
|
||||||
|
warnings.add(
|
||||||
|
"country_of_origination_format_not_imported",
|
||||||
|
"CountryOfOriginationFormat was not imported because VoCat has no trusted runtime country source; a P-Access-Network-Info value must not be fabricated",
|
||||||
|
fullPath,
|
||||||
|
)
|
||||||
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
||||||
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"IMSConfig": map[string]any{
|
"IMSConfig": map[string]any{
|
||||||
"EnableWiFiCallingWithoutEntitlement": true,
|
"EnableWiFiCallingWithoutEntitlement": true,
|
||||||
"Signaling": map[string]any{"UseIPSec": true},
|
"Signaling": map[string]any{
|
||||||
"Media": map[string]any{"SupportPCMA": false},
|
"UseIPSec": true,
|
||||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
"CountryOfOriginationFormat": "PANI",
|
||||||
|
},
|
||||||
|
"Media": map[string]any{"SupportPCMA": false},
|
||||||
|
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -79,6 +82,7 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
|||||||
"entitlement_bypass_ignored",
|
"entitlement_bypass_ignored",
|
||||||
"apn_settings_ignored",
|
"apn_settings_ignored",
|
||||||
"device_media_overrides_ignored",
|
"device_media_overrides_ignored",
|
||||||
|
"country_of_origination_format_not_imported",
|
||||||
"emergency_settings_ignored",
|
"emergency_settings_ignored",
|
||||||
} {
|
} {
|
||||||
if !hasIPCCWarning(result.Warnings, code) {
|
if !hasIPCCWarning(result.Warnings, code) {
|
||||||
|
|||||||
+11741
-81
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ const (
|
|||||||
usimAIDPrefix = "A0000000871002"
|
usimAIDPrefix = "A0000000871002"
|
||||||
isimAIDPrefix = "A0000000871004"
|
isimAIDPrefix = "A0000000871004"
|
||||||
efADDecimal = 28589 // 0x6FAD
|
efADDecimal = 28589 // 0x6FAD
|
||||||
|
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
|
||||||
channelCleanupTimeout = 3 * time.Second
|
channelCleanupTimeout = 3 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -239,14 +240,79 @@ func (adapter *EC20Adapter) readHomePLMN(
|
|||||||
return mcc, mnc, nil
|
return mcc, mnc, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
|
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic.
|
||||||
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
|
|
||||||
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
||||||
return mcc, mnc, nil
|
return mcc, mnc, nil
|
||||||
}
|
}
|
||||||
|
// 3GPP TS 31.102 Section 4.2.84: Query EF_EHPLMN (Equivalent Home PLMN).
|
||||||
|
if ehplmns, err := adapter.readEHPLMN(ctx, deviceID); err == nil && len(ehplmns) > 0 {
|
||||||
|
first := ehplmns[0]
|
||||||
|
if len(first) >= 5 {
|
||||||
|
return first[:3], first[3:], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", "", efErr
|
return "", "", efErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (adapter *EC20Adapter) readEHPLMN(
|
||||||
|
ctx context.Context,
|
||||||
|
deviceID string,
|
||||||
|
) ([]string, error) {
|
||||||
|
commands := []string{
|
||||||
|
fmt.Sprintf("AT+CRSM=176,%d,0,0,0", efEHPLMNDecimal),
|
||||||
|
fmt.Sprintf("AT+CRSM=176,%d,0,0,12", efEHPLMNDecimal),
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, command := range commands {
|
||||||
|
response, err := adapter.execute(ctx, deviceID, command)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := parseCRSMData(response)
|
||||||
|
if err != nil || len(data) < 3 {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plmns := parsePLMNListFromBytes(data)
|
||||||
|
if len(plmns) > 0 {
|
||||||
|
return plmns, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = errors.New("vocat: EF_EHPLMN is empty or unavailable")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePLMNListFromBytes(data []byte) []string {
|
||||||
|
var plmns []string
|
||||||
|
for i := 0; i+3 <= len(data); i += 3 {
|
||||||
|
b1, b2, b3 := data[i], data[i+1], data[i+2]
|
||||||
|
mcc1 := b1 & 0x0f
|
||||||
|
mcc2 := (b1 >> 4) & 0x0f
|
||||||
|
mcc3 := b2 & 0x0f
|
||||||
|
mnc3 := (b2 >> 4) & 0x0f
|
||||||
|
mnc1 := b3 & 0x0f
|
||||||
|
mnc2 := (b3 >> 4) & 0x0f
|
||||||
|
|
||||||
|
if mcc1 > 9 || mcc2 > 9 || mcc3 > 9 || mnc1 > 9 || mnc2 > 9 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mcc := fmt.Sprintf("%d%d%d", mcc1, mcc2, mcc3)
|
||||||
|
var mnc string
|
||||||
|
if mnc3 <= 9 {
|
||||||
|
mnc = fmt.Sprintf("%d%d%d", mnc1, mnc2, mnc3)
|
||||||
|
} else {
|
||||||
|
mnc = fmt.Sprintf("%d%d", mnc1, mnc2)
|
||||||
|
}
|
||||||
|
if len(mcc) == 3 && (len(mnc) == 2 || len(mnc) == 3) {
|
||||||
|
plmns = append(plmns, mcc+mnc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plmns
|
||||||
|
}
|
||||||
|
|
||||||
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
||||||
assignments := []struct {
|
assignments := []struct {
|
||||||
prefix string
|
prefix string
|
||||||
|
|||||||
@@ -280,6 +280,239 @@ func decryptPayloads(
|
|||||||
return header, payloads, nil
|
return header, payloads, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultIKEFragmentSize = 1100
|
||||||
|
|
||||||
|
func encryptPayloadsFragmented(
|
||||||
|
header ikeHeader,
|
||||||
|
inner []payload,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
encryptionKey []byte,
|
||||||
|
integrityKey []byte,
|
||||||
|
maxFragmentSize int,
|
||||||
|
random io.Reader,
|
||||||
|
) ([][]byte, error) {
|
||||||
|
if random == nil {
|
||||||
|
random = rand.Reader
|
||||||
|
}
|
||||||
|
if maxFragmentSize <= 0 {
|
||||||
|
maxFragmentSize = defaultIKEFragmentSize
|
||||||
|
}
|
||||||
|
first, plaintext, err := marshalPayloadChain(inner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ike: initialize AES: %w", err)
|
||||||
|
}
|
||||||
|
_, checksumLength, err := suite.integrityLengths()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maxChunk := maxFragmentSize - ikeHeaderLength - 8 - block.BlockSize() - block.BlockSize() - checksumLength
|
||||||
|
if maxChunk < 64 {
|
||||||
|
maxChunk = 64
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks [][]byte
|
||||||
|
for len(plaintext) > 0 {
|
||||||
|
take := len(plaintext)
|
||||||
|
if take > maxChunk {
|
||||||
|
take = maxChunk
|
||||||
|
}
|
||||||
|
chunks = append(chunks, plaintext[:take])
|
||||||
|
plaintext = plaintext[take:]
|
||||||
|
}
|
||||||
|
totalFragments := uint16(len(chunks))
|
||||||
|
if totalFragments == 0 {
|
||||||
|
totalFragments = 1
|
||||||
|
chunks = [][]byte{nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
var packets [][]byte
|
||||||
|
for index, chunk := range chunks {
|
||||||
|
fragNum := uint16(index + 1)
|
||||||
|
fragNext := uint8(payloadNone)
|
||||||
|
if fragNum == 1 {
|
||||||
|
fragNext = first
|
||||||
|
}
|
||||||
|
|
||||||
|
paddingLength := block.BlockSize() - (len(chunk)+1)%block.BlockSize()
|
||||||
|
if paddingLength == block.BlockSize() {
|
||||||
|
paddingLength = 0
|
||||||
|
}
|
||||||
|
padding := make([]byte, paddingLength)
|
||||||
|
if _, err := io.ReadFull(random, padding); err != nil {
|
||||||
|
return nil, fmt.Errorf("ike: generate encrypted payload padding: %w", err)
|
||||||
|
}
|
||||||
|
paddedChunk := append(append([]byte(nil), chunk...), padding...)
|
||||||
|
paddedChunk = append(paddedChunk, byte(paddingLength))
|
||||||
|
|
||||||
|
iv := make([]byte, block.BlockSize())
|
||||||
|
if _, err := io.ReadFull(random, iv); err != nil {
|
||||||
|
return nil, fmt.Errorf("ike: generate encrypted payload IV: %w", err)
|
||||||
|
}
|
||||||
|
ciphertext := make([]byte, len(paddedChunk))
|
||||||
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, paddedChunk)
|
||||||
|
|
||||||
|
skfLength := 4 + 4 + len(iv) + len(ciphertext) + checksumLength
|
||||||
|
if skfLength > 65535 {
|
||||||
|
return nil, errors.New("ike: encrypted fragment exceeds 65535 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make([]byte, skfLength)
|
||||||
|
body[0] = fragNext
|
||||||
|
body[1] = 0
|
||||||
|
binary.BigEndian.PutUint16(body[2:4], uint16(skfLength))
|
||||||
|
binary.BigEndian.PutUint16(body[4:6], fragNum)
|
||||||
|
binary.BigEndian.PutUint16(body[6:8], totalFragments)
|
||||||
|
copy(body[8:], iv)
|
||||||
|
copy(body[8+len(iv):], ciphertext)
|
||||||
|
|
||||||
|
fragHeader := header
|
||||||
|
fragHeader.NextPayload = payloadEncryptedFragment
|
||||||
|
packet := fragHeader.marshal(body)
|
||||||
|
checksum, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
copy(packet[len(packet)-checksumLength:], checksum)
|
||||||
|
packets = append(packets, packet)
|
||||||
|
}
|
||||||
|
return packets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptSingleFragment(
|
||||||
|
packet []byte,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
encryptionKey []byte,
|
||||||
|
integrityKey []byte,
|
||||||
|
) (ikeHeader, uint8, uint16, uint16, []byte, error) {
|
||||||
|
header, body, err := parseIKEPacket(packet)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
if header.NextPayload != payloadEncryptedFragment || len(body) < 8 {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: message is not an encrypted IKE fragment", errUnexpectedPacket)
|
||||||
|
}
|
||||||
|
skfLength := int(binary.BigEndian.Uint16(body[2:4]))
|
||||||
|
if skfLength != len(body) {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment length mismatch", errMalformedPacket)
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("ike: initialize AES: %w", err)
|
||||||
|
}
|
||||||
|
_, checksumLength, err := suite.integrityLengths()
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
if len(body) < 8+block.BlockSize()+block.BlockSize()+checksumLength {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment is too short", errMalformedPacket)
|
||||||
|
}
|
||||||
|
expected, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
actual := packet[len(packet)-checksumLength:]
|
||||||
|
if subtle.ConstantTimeCompare(actual, expected) != 1 {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, errIntegrityMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
fragNext := body[0]
|
||||||
|
fragNum := binary.BigEndian.Uint16(body[4:6])
|
||||||
|
totalFrags := binary.BigEndian.Uint16(body[6:8])
|
||||||
|
if fragNum == 0 || totalFrags == 0 || fragNum > totalFrags {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid fragment numbers %d/%d", errMalformedPacket, fragNum, totalFrags)
|
||||||
|
}
|
||||||
|
|
||||||
|
ivStart := 8
|
||||||
|
ciphertextStart := ivStart + block.BlockSize()
|
||||||
|
ciphertextEnd := len(body) - checksumLength
|
||||||
|
ciphertext := body[ciphertextStart:ciphertextEnd]
|
||||||
|
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: fragment ciphertext is not block aligned", errMalformedPacket)
|
||||||
|
}
|
||||||
|
plaintext := make([]byte, len(ciphertext))
|
||||||
|
cipher.NewCBCDecrypter(block, body[ivStart:ciphertextStart]).CryptBlocks(plaintext, ciphertext)
|
||||||
|
paddingLength := int(plaintext[len(plaintext)-1])
|
||||||
|
if paddingLength+1 > len(plaintext) {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid encrypted fragment padding", errMalformedPacket)
|
||||||
|
}
|
||||||
|
plaintext = plaintext[:len(plaintext)-paddingLength-1]
|
||||||
|
return header, fragNext, fragNum, totalFrags, plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptPayloadsAny(
|
||||||
|
packet []byte,
|
||||||
|
fragments [][]byte,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
encryptionKey []byte,
|
||||||
|
integrityKey []byte,
|
||||||
|
) (ikeHeader, []payload, error) {
|
||||||
|
if len(fragments) > 0 {
|
||||||
|
var (
|
||||||
|
firstHeader ikeHeader
|
||||||
|
firstNext uint8
|
||||||
|
totalExpected uint16
|
||||||
|
plaintexts = make(map[uint16][]byte)
|
||||||
|
)
|
||||||
|
for _, fragPacket := range fragments {
|
||||||
|
hdr, next, num, total, plain, err := decryptSingleFragment(fragPacket, suite, encryptionKey, integrityKey)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if totalExpected == 0 {
|
||||||
|
firstHeader = hdr
|
||||||
|
totalExpected = total
|
||||||
|
} else if total != totalExpected || hdr.MessageID != firstHeader.MessageID || hdr.Exchange != firstHeader.Exchange {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: inconsistent fragment headers", errMalformedPacket)
|
||||||
|
}
|
||||||
|
if num == 1 {
|
||||||
|
firstNext = next
|
||||||
|
}
|
||||||
|
plaintexts[num] = plain
|
||||||
|
}
|
||||||
|
if uint16(len(plaintexts)) != totalExpected {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragments: received %d of %d", errMalformedPacket, len(plaintexts), totalExpected)
|
||||||
|
}
|
||||||
|
var fullPlaintext []byte
|
||||||
|
for i := uint16(1); i <= totalExpected; i++ {
|
||||||
|
chunk, ok := plaintexts[i]
|
||||||
|
if !ok {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragment %d", errMalformedPacket, i)
|
||||||
|
}
|
||||||
|
fullPlaintext = append(fullPlaintext, chunk...)
|
||||||
|
}
|
||||||
|
payloads, err := parsePayloadChain(firstNext, fullPlaintext)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
return firstHeader, payloads, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
header, _, err := parseIKEPacket(packet)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if header.NextPayload == payloadEncryptedFragment {
|
||||||
|
hdr, next, num, total, plain, err := decryptSingleFragment(packet, suite, encryptionKey, integrityKey)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if num != 1 || total != 1 {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: standalone fragment with total=%d", errMalformedPacket, total)
|
||||||
|
}
|
||||||
|
payloads, err := parsePayloadChain(next, plain)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
return hdr, payloads, nil
|
||||||
|
}
|
||||||
|
return decryptPayloads(packet, suite, encryptionKey, integrityKey)
|
||||||
|
}
|
||||||
|
|
||||||
var modpPrimes = map[uint16]string{
|
var modpPrimes = map[uint16]string{
|
||||||
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
||||||
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
||||||
|
|||||||
@@ -113,3 +113,80 @@ func TestIKEKeyDerivationSeparatesDirections(t *testing.T) {
|
|||||||
t.Fatal("initiator and responder keys were not separated")
|
t.Fatal("initiator and responder keys were not separated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRFC7383FragmentationAndReassembly(t *testing.T) {
|
||||||
|
suite := legacyTestSuite()
|
||||||
|
encryptionKey := bytes.Repeat([]byte{0x11}, 16)
|
||||||
|
integrityKey := bytes.Repeat([]byte{0x22}, 20)
|
||||||
|
header := ikeHeader{
|
||||||
|
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||||
|
ResponderSPI: [8]byte{8, 7, 6, 5, 4, 3, 2, 1},
|
||||||
|
Exchange: exchangeIKEAuth,
|
||||||
|
Flags: flagInitiator,
|
||||||
|
MessageID: 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
largeCertData := bytes.Repeat([]byte{0xAB, 0xCD, 0xEF, 0x01}, 400) // 1600 bytes
|
||||||
|
inner := []payload{
|
||||||
|
{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u', 's', 'e', 'r'}},
|
||||||
|
{Type: payloadCert, Body: largeCertData},
|
||||||
|
{Type: payloadAuth, Body: bytes.Repeat([]byte{0x55}, 64)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragment into chunks with max fragment size 600 bytes
|
||||||
|
packets, err := encryptPayloadsFragmented(
|
||||||
|
header,
|
||||||
|
inner,
|
||||||
|
suite,
|
||||||
|
encryptionKey,
|
||||||
|
integrityKey,
|
||||||
|
600,
|
||||||
|
bytes.NewReader(bytes.Repeat([]byte{0x77}, 1024)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encryptPayloadsFragmented() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(packets) < 3 {
|
||||||
|
t.Fatalf("expected at least 3 fragments for large payload, got %d", len(packets))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, pkt := range packets {
|
||||||
|
hdr, body, parseErr := parseIKEPacket(pkt)
|
||||||
|
if parseErr != nil {
|
||||||
|
t.Fatalf("fragment %d parse error: %v", i+1, parseErr)
|
||||||
|
}
|
||||||
|
if hdr.NextPayload != payloadEncryptedFragment {
|
||||||
|
t.Fatalf("fragment %d NextPayload = %d, want %d (payloadEncryptedFragment)", i+1, hdr.NextPayload, payloadEncryptedFragment)
|
||||||
|
}
|
||||||
|
if len(body) < 8 {
|
||||||
|
t.Fatalf("fragment %d body too short", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt and reassemble
|
||||||
|
decodedHeader, decoded, err := decryptPayloadsAny(nil, packets, suite, encryptionKey, integrityKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decryptPayloadsAny() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decodedHeader.MessageID != header.MessageID || len(decoded) != len(inner) {
|
||||||
|
t.Fatalf("reassembled payload mismatch: header=%#v, count=%d, want=%d", decodedHeader, len(decoded), len(inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range inner {
|
||||||
|
if decoded[index].Type != inner[index].Type || !bytes.Equal(decoded[index].Body, inner[index].Body) {
|
||||||
|
t.Fatalf("decoded payload %d = %#v, want %#v", index, decoded[index], inner[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tamper detection on second fragment
|
||||||
|
tamperedPackets := make([][]byte, len(packets))
|
||||||
|
for i := range packets {
|
||||||
|
tamperedPackets[i] = append([]byte(nil), packets[i]...)
|
||||||
|
}
|
||||||
|
tamperedPackets[1][len(tamperedPackets[1])-1] ^= 0x55
|
||||||
|
if _, _, err := decryptPayloadsAny(nil, tamperedPackets, suite, encryptionKey, integrityKey); !errors.Is(err, errIntegrityMismatch) {
|
||||||
|
t.Fatalf("tampered fragment decrypt error = %v, want errIntegrityMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -264,8 +264,13 @@ func permanentAKAIdentity(identity vowifi.SIMIdentity) ([]byte, error) {
|
|||||||
return nil, errors.New("ike: IMSI contains a non-digit")
|
return nil, errors.New("ike: IMSI contains a non-digit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
mcc := strings.TrimSpace(profile.RouteMCC)
|
||||||
|
mnc := strings.TrimSpace(profile.RouteMNC)
|
||||||
|
if mcc == "" || mnc == "" {
|
||||||
|
mcc = strings.TrimSpace(identity.HomeMCC)
|
||||||
|
mnc = strings.TrimSpace(identity.HomeMNC)
|
||||||
|
}
|
||||||
if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) {
|
if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) {
|
||||||
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
|
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,48 +28,74 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
|||||||
if resolver == nil {
|
if resolver == nil {
|
||||||
resolver = net.DefaultResolver
|
resolver = net.DefaultResolver
|
||||||
}
|
}
|
||||||
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
|
|
||||||
if systemErr == nil && len(addresses) > 0 {
|
|
||||||
return addresses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
hostsToTry := []string{normalized}
|
||||||
if subnet == "" {
|
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
||||||
if systemErr != nil {
|
hostsToTry = append(hostsToTry, alt)
|
||||||
return nil, systemErr
|
|
||||||
}
|
|
||||||
return nil, errors.New("ePDG did not resolve to an IP address")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var systemErr error
|
||||||
|
for _, targetHost := range hostsToTry {
|
||||||
|
addresses, err := resolver.LookupIPAddr(ctx, targetHost)
|
||||||
|
if err == nil {
|
||||||
|
valid := filterValidPublicEPDGAddresses(addresses)
|
||||||
|
if len(valid) > 0 {
|
||||||
|
return valid, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
systemErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||||
client := &http.Client{Timeout: 8 * time.Second}
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
var fallbackErr error
|
var fallbackErr error
|
||||||
// Vodafone's authoritative response has a 60-second TTL and recursive
|
|
||||||
// resolvers can briefly cache the global CNAME without its geo-restricted
|
for _, targetHost := range hostsToTry {
|
||||||
// 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++ {
|
|
||||||
var fallback []net.IPAddr
|
var fallback []net.IPAddr
|
||||||
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, normalized, subnet)
|
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
|
||||||
if fallbackErr == nil && len(fallback) > 0 {
|
if fallbackErr == nil && len(fallback) > 0 {
|
||||||
return fallback, nil
|
return fallback, nil
|
||||||
}
|
}
|
||||||
if attempt+1 < fallbackAttempts {
|
|
||||||
select {
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if systemErr == nil {
|
if systemErr == nil {
|
||||||
systemErr = errors.New("system DNS returned no IP addresses")
|
systemErr = errors.New("system DNS returned no usable public IP addresses")
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("system DNS failed (%v); geographic DNS fallback failed: %w", systemErr, fallbackErr)
|
return nil, fmt.Errorf("system DNS failed (%v); geographic DNS fallback failed: %w", systemErr, fallbackErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterValidPublicEPDGAddresses(addresses []net.IPAddr) []net.IPAddr {
|
||||||
|
result := make([]net.IPAddr, 0, len(addresses))
|
||||||
|
for _, addr := range addresses {
|
||||||
|
if addr.IP == nil || addr.IP.IsLoopback() || addr.IP.IsUnspecified() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, addr)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func alternate3GPPHostname(host string) string {
|
||||||
|
const prefix = "epdg.epc.mnc"
|
||||||
|
if !strings.HasPrefix(host, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := host[len(prefix):]
|
||||||
|
dot := strings.Index(rest, ".")
|
||||||
|
if dot <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
mnc := rest[:dot]
|
||||||
|
suffix := rest[dot:]
|
||||||
|
if len(mnc) == 3 && strings.HasPrefix(mnc, "0") {
|
||||||
|
return prefix + mnc[1:] + suffix
|
||||||
|
}
|
||||||
|
if len(mnc) == 2 {
|
||||||
|
return prefix + "0" + mnc + suffix
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func resolveEPDGWithECS(
|
func resolveEPDGWithECS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *http.Client,
|
client *http.Client,
|
||||||
@@ -85,7 +111,9 @@ func resolveEPDGWithECS(
|
|||||||
query := parsed.Query()
|
query := parsed.Query()
|
||||||
query.Set("name", strings.TrimSpace(host))
|
query.Set("name", strings.TrimSpace(host))
|
||||||
query.Set("type", "A")
|
query.Set("type", "A")
|
||||||
query.Set("edns_client_subnet", strings.TrimSpace(subnet))
|
if strings.TrimSpace(subnet) != "" {
|
||||||
|
query.Set("edns_client_subnet", strings.TrimSpace(subnet))
|
||||||
|
}
|
||||||
parsed.RawQuery = query.Encode()
|
parsed.RawQuery = query.Encode()
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||||
@@ -116,7 +144,7 @@ func resolveEPDGWithECS(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), "."))
|
ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), "."))
|
||||||
if ip == nil {
|
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
duplicate := false
|
duplicate := false
|
||||||
|
|||||||
+103
-32
@@ -187,6 +187,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
{Type: payloadNonce, Body: initiatorNonce},
|
{Type: payloadNonce, Body: initiatorNonce},
|
||||||
makeNotify(notifyNATSource, sourceHash),
|
makeNotify(notifyNATSource, sourceHash),
|
||||||
makeNotify(notifyNATDestination, destinationHash),
|
makeNotify(notifyNATDestination, destinationHash),
|
||||||
|
makeNotify(notifyFragmentationSupported, nil),
|
||||||
}
|
}
|
||||||
var (
|
var (
|
||||||
initRequest []byte
|
initRequest []byte
|
||||||
@@ -243,6 +244,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
peerSupportsFragmentation := hasNotifyType(initResponsePayloads, notifyFragmentationSupported)
|
||||||
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -345,21 +347,19 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: 1,
|
MessageID: 1,
|
||||||
}
|
}
|
||||||
authRequest, err := encryptPayloads(authHeader, firstAuthPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
_, authResponsePayloads, err := sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
authHeader,
|
||||||
authResponse, err := transport.RoundTrip(ctx, authRequest)
|
firstAuthPayloads,
|
||||||
if err != nil {
|
ikeSuite,
|
||||||
return nil, err
|
keys,
|
||||||
}
|
peerSupportsFragmentation,
|
||||||
authResponseHeader, authResponsePayloads, err := decryptAndValidate(
|
provider.config.Random,
|
||||||
authResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, 1, ikeSuite, keys,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = authResponseHeader
|
|
||||||
serverName := strings.TrimSpace(provider.config.ServerName)
|
serverName := strings.TrimSpace(provider.config.ServerName)
|
||||||
if serverName == "" {
|
if serverName == "" {
|
||||||
serverName = epdg
|
serverName = epdg
|
||||||
@@ -409,27 +409,27 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
requestPayloads = append(requestPayloads, deviceIdentity)
|
requestPayloads = append(requestPayloads, deviceIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eapRequest, err := encryptPayloads(ikeHeader{
|
eapHeader := ikeHeader{
|
||||||
InitiatorSPI: initiatorSPI,
|
InitiatorSPI: initiatorSPI,
|
||||||
ResponderSPI: responseHeader.ResponderSPI,
|
ResponderSPI: responseHeader.ResponderSPI,
|
||||||
Exchange: exchangeIKEAuth,
|
Exchange: exchangeIKEAuth,
|
||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
||||||
return nil, notifyErr
|
return nil, notifyErr
|
||||||
} else if requested {
|
} else if requested {
|
||||||
deviceIdentityPending = true
|
deviceIdentityPending = true
|
||||||
}
|
}
|
||||||
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
|
_, currentPayloads, err = sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
eapHeader,
|
||||||
_, currentPayloads, err = decryptAndValidate(
|
requestPayloads,
|
||||||
eapResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
ikeSuite,
|
||||||
|
keys,
|
||||||
|
peerSupportsFragmentation,
|
||||||
|
provider.config.Random,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -454,22 +454,22 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}
|
}
|
||||||
messageID++
|
messageID++
|
||||||
cleanupMessageID = messageID + 1
|
cleanupMessageID = messageID + 1
|
||||||
finalRequest, err := encryptPayloads(ikeHeader{
|
finalHeader := ikeHeader{
|
||||||
InitiatorSPI: initiatorSPI,
|
InitiatorSPI: initiatorSPI,
|
||||||
ResponderSPI: responseHeader.ResponderSPI,
|
ResponderSPI: responseHeader.ResponderSPI,
|
||||||
Exchange: exchangeIKEAuth,
|
Exchange: exchangeIKEAuth,
|
||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
}, []payload{initiatorAUTH}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
finalResponse, err := transport.RoundTrip(ctx, finalRequest)
|
_, finalPayloads, err := sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
finalHeader,
|
||||||
_, finalPayloads, err := decryptAndValidate(
|
[]payload{initiatorAUTH},
|
||||||
finalResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
ikeSuite,
|
||||||
|
keys,
|
||||||
|
peerSupportsFragmentation,
|
||||||
|
provider.config.Random,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -763,7 +763,7 @@ func decryptAndValidate(
|
|||||||
suite negotiatedSuite,
|
suite negotiatedSuite,
|
||||||
keys ikeKeys,
|
keys ikeKeys,
|
||||||
) (ikeHeader, []payload, error) {
|
) (ikeHeader, []payload, error) {
|
||||||
header, payloads, err := decryptPayloads(packet, suite, keys.SKer, keys.SKar)
|
header, payloads, err := decryptPayloadsAny(packet, nil, suite, keys.SKer, keys.SKar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ikeHeader{}, nil, err
|
return ikeHeader{}, nil, err
|
||||||
}
|
}
|
||||||
@@ -778,6 +778,77 @@ func decryptAndValidate(
|
|||||||
return header, payloads, nil
|
return header, payloads, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decryptAndValidateFragments(
|
||||||
|
packets [][]byte,
|
||||||
|
initiatorSPI [8]byte,
|
||||||
|
responderSPI [8]byte,
|
||||||
|
exchange uint8,
|
||||||
|
messageID uint32,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
keys ikeKeys,
|
||||||
|
) (ikeHeader, []payload, error) {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return ikeHeader{}, nil, errors.New("ike: empty exchange response")
|
||||||
|
}
|
||||||
|
if len(packets) == 1 {
|
||||||
|
return decryptAndValidate(packets[0], initiatorSPI, responderSPI, exchange, messageID, suite, keys)
|
||||||
|
}
|
||||||
|
header, payloads, err := decryptPayloadsAny(nil, packets, suite, keys.SKer, keys.SKar)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if header.InitiatorSPI != initiatorSPI ||
|
||||||
|
header.ResponderSPI != responderSPI ||
|
||||||
|
header.Exchange != exchange ||
|
||||||
|
header.MessageID != messageID ||
|
||||||
|
header.Flags&flagResponse == 0 ||
|
||||||
|
header.Flags&flagInitiator != 0 {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: encrypted response header does not match the request", errUnexpectedPacket)
|
||||||
|
}
|
||||||
|
return header, payloads, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendAndReceiveIKEPayloads(
|
||||||
|
ctx context.Context,
|
||||||
|
transport datagramTransport,
|
||||||
|
header ikeHeader,
|
||||||
|
payloads []payload,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
keys ikeKeys,
|
||||||
|
peerSupportsFragmentation bool,
|
||||||
|
random io.Reader,
|
||||||
|
) (ikeHeader, []payload, error) {
|
||||||
|
var outboundPackets [][]byte
|
||||||
|
var err error
|
||||||
|
if peerSupportsFragmentation {
|
||||||
|
outboundPackets, err = encryptPayloadsFragmented(header, payloads, suite, keys.SKei, keys.SKai, defaultIKEFragmentSize, random)
|
||||||
|
} else {
|
||||||
|
pkt, encryptErr := encryptPayloads(header, payloads, suite, keys.SKei, keys.SKai, random)
|
||||||
|
if encryptErr != nil {
|
||||||
|
return ikeHeader{}, nil, encryptErr
|
||||||
|
}
|
||||||
|
outboundPackets = [][]byte{pkt}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
inboundPackets, err := transport.RoundTripExchange(ctx, outboundPackets)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
return decryptAndValidateFragments(inboundPackets, header.InitiatorSPI, header.ResponderSPI, header.Exchange, header.MessageID, suite, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNotifyType(payloads []payload, notifyType uint16) bool {
|
||||||
|
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||||
|
kind, _, err := parseNotify(item)
|
||||||
|
if err == nil && kind == notifyType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||||
|
|
||||||
type invalidKEPayloadError struct {
|
type invalidKEPayloadError struct {
|
||||||
|
|||||||
@@ -18,25 +18,6 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
|
|||||||
|
|
||||||
type constantReader struct{ value byte }
|
type constantReader struct{ value byte }
|
||||||
|
|
||||||
func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
|
||||||
for _, item := range []struct {
|
|
||||||
mcc string
|
|
||||||
mnc string
|
|
||||||
}{
|
|
||||||
{mcc: "234", mnc: "15"},
|
|
||||||
{mcc: "204", mnc: "04"},
|
|
||||||
{mcc: "204", mnc: "004"},
|
|
||||||
} {
|
|
||||||
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
|
||||||
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
|
||||||
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
|
||||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
errNoProposalChosen,
|
errNoProposalChosen,
|
||||||
@@ -95,7 +76,7 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
|
func (transport *firstAuthCaptureTransport) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||||
transport.calls++
|
transport.calls++
|
||||||
if len(transport.cookieChallenge) > 0 {
|
if len(transport.cookieChallenge) > 0 {
|
||||||
switch transport.calls {
|
switch transport.calls {
|
||||||
@@ -125,6 +106,17 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (transport *firstAuthCaptureTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("test: empty outbound packets")
|
||||||
|
}
|
||||||
|
resp, err := transport.RoundTrip(ctx, packets[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return [][]byte{resp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
||||||
header, _, err := parseIKEPacket(packet)
|
header, _, err := parseIKEPacket(packet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,8 +157,8 @@ func (transport *firstAuthCaptureTransport) verifyIKECookie(packet []byte) error
|
|||||||
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
||||||
}
|
}
|
||||||
cookies := payloadsOfType(payloads, payloadNotify)
|
cookies := payloadsOfType(payloads, payloadNotify)
|
||||||
if len(cookies) != 3 {
|
if len(cookies) != 4 {
|
||||||
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", len(cookies))
|
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 4", len(cookies))
|
||||||
}
|
}
|
||||||
found := false
|
found := false
|
||||||
for _, item := range cookies {
|
for _, item := range cookies {
|
||||||
@@ -208,7 +200,7 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
|||||||
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
||||||
wantGroup := transport.wantGroup
|
wantGroup := transport.wantGroup
|
||||||
if wantGroup == 0 {
|
if wantGroup == 0 {
|
||||||
wantGroup = dhMODP1024
|
wantGroup = dhMODP2048
|
||||||
}
|
}
|
||||||
wantKELength := 128
|
wantKELength := 128
|
||||||
if wantGroup == dhMODP2048 {
|
if wantGroup == dhMODP2048 {
|
||||||
@@ -464,44 +456,5 @@ func TestProviderBoundsRepeatedIKEInitCookieChallenges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
|
||||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
|
||||||
provider, err := NewProvider(Config{
|
|
||||||
Random: constantReader{value: 0x42},
|
|
||||||
Timeout: time.Second,
|
|
||||||
Installer: unusedInstaller{},
|
|
||||||
APN: "ims",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
provider.transportFactory = func(
|
|
||||||
context.Context,
|
|
||||||
transportConfig,
|
|
||||||
vowifi.ProxyRoute,
|
|
||||||
string,
|
|
||||||
) (datagramTransport, error) {
|
|
||||||
return capture, nil
|
|
||||||
}
|
|
||||||
aka := &testAKAProvider{}
|
|
||||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
|
||||||
DeviceID: "ec20-o2",
|
|
||||||
Identity: vowifi.SIMIdentity{
|
|
||||||
ICCID: "8949200000000000000",
|
|
||||||
IMSI: "262030123456789",
|
|
||||||
HomeMCC: "262",
|
|
||||||
HomeMNC: "03",
|
|
||||||
},
|
|
||||||
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
|
|
||||||
AKA: aka,
|
|
||||||
})
|
|
||||||
if !errors.Is(err, errFirstAuthObserved) {
|
|
||||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
|
||||||
}
|
|
||||||
if capture.calls != 2 || capture.floated || aka.calls != 0 {
|
|
||||||
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ io.Reader = constantReader{}
|
var _ io.Reader = constantReader{}
|
||||||
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
|
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func newSessionRelay(
|
|||||||
keepalive time.Duration,
|
keepalive time.Duration,
|
||||||
) *sessionRelay {
|
) *sessionRelay {
|
||||||
if keepalive <= 0 {
|
if keepalive <= 0 {
|
||||||
keepalive = 20 * time.Second
|
keepalive = 15 * time.Second
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
relay := &sessionRelay{
|
relay := &sessionRelay{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ike
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -49,6 +50,16 @@ func (*fakeSessionTransport) Float(context.Context) error { return nil }
|
|||||||
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
|
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
|
||||||
return nil, context.DeadlineExceeded
|
return nil, context.DeadlineExceeded
|
||||||
}
|
}
|
||||||
|
func (t *fakeSessionTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("empty outbound packets")
|
||||||
|
}
|
||||||
|
resp, err := t.RoundTrip(ctx, packets[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return [][]byte{resp}, nil
|
||||||
|
}
|
||||||
func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error {
|
func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error {
|
||||||
return transport.SendSessionPacket(ctx, packet, false)
|
return transport.SendSessionPacket(ctx, packet, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type datagramTransport interface {
|
|||||||
RemoteAddr() *net.UDPAddr
|
RemoteAddr() *net.UDPAddr
|
||||||
Float(context.Context) error
|
Float(context.Context) error
|
||||||
RoundTrip(context.Context, []byte) ([]byte, error)
|
RoundTrip(context.Context, []byte) ([]byte, error)
|
||||||
|
RoundTripExchange(context.Context, [][]byte) ([][]byte, error)
|
||||||
SendESP(context.Context, []byte) error
|
SendESP(context.Context, []byte) error
|
||||||
ReceiveESP(context.Context, []byte) (int, error)
|
ReceiveESP(context.Context, []byte) (int, error)
|
||||||
SendSessionPacket(context.Context, []byte, bool) error
|
SendSessionPacket(context.Context, []byte, bool) error
|
||||||
@@ -96,6 +97,29 @@ func roundTripDatagram(
|
|||||||
read func([]byte, time.Time) (int, error),
|
read func([]byte, time.Time) (int, error),
|
||||||
packet []byte,
|
packet []byte,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
|
writeAll := func(values [][]byte) error {
|
||||||
|
if len(values) > 0 {
|
||||||
|
return write(values[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
responses, err := roundTripFragments(ctx, timeout, writeAll, read, [][]byte{packet})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(responses) == 0 {
|
||||||
|
return nil, errors.New("ike: empty datagram response")
|
||||||
|
}
|
||||||
|
return responses[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTripFragments(
|
||||||
|
ctx context.Context,
|
||||||
|
timeout time.Duration,
|
||||||
|
writeAll func([][]byte) error,
|
||||||
|
read func([]byte, time.Time) (int, error),
|
||||||
|
packets [][]byte,
|
||||||
|
) ([][]byte, error) {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -110,20 +134,44 @@ func roundTripDatagram(
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := write(packet); err != nil {
|
if err := writeAll(packets); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
attemptDeadline := time.Now().Add(interval)
|
attemptDeadline := time.Now().Add(interval)
|
||||||
if deadline.Before(attemptDeadline) {
|
if deadline.Before(attemptDeadline) {
|
||||||
attemptDeadline = deadline
|
attemptDeadline = deadline
|
||||||
}
|
}
|
||||||
|
var (
|
||||||
|
totalExpected uint16
|
||||||
|
fragments = make(map[uint16][]byte)
|
||||||
|
)
|
||||||
for time.Now().Before(attemptDeadline) {
|
for time.Now().Before(attemptDeadline) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n, err := read(buffer, attemptDeadline)
|
n, err := read(buffer, attemptDeadline)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return append([]byte(nil), buffer[:n]...), nil
|
pkt := append([]byte(nil), buffer[:n]...)
|
||||||
|
header, body, parseErr := parseIKEPacket(pkt)
|
||||||
|
if parseErr == nil && header.NextPayload == payloadEncryptedFragment && len(body) >= 8 {
|
||||||
|
fragNum := binary.BigEndian.Uint16(body[4:6])
|
||||||
|
total := binary.BigEndian.Uint16(body[6:8])
|
||||||
|
if total > 1 {
|
||||||
|
if totalExpected == 0 {
|
||||||
|
totalExpected = total
|
||||||
|
}
|
||||||
|
fragments[fragNum] = pkt
|
||||||
|
if uint16(len(fragments)) == totalExpected {
|
||||||
|
res := make([][]byte, 0, totalExpected)
|
||||||
|
for i := uint16(1); i <= totalExpected; i++ {
|
||||||
|
res = append(res, fragments[i])
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [][]byte{pkt}, nil
|
||||||
}
|
}
|
||||||
if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() {
|
if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@@ -222,25 +270,47 @@ func (transport *directUDP) Float(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||||
|
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(responses) == 0 {
|
||||||
|
return nil, errors.New("ike: empty exchange response")
|
||||||
|
}
|
||||||
|
return responses[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *directUDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
defer transport.mu.Unlock()
|
defer transport.mu.Unlock()
|
||||||
if transport.conn == nil {
|
if transport.conn == nil {
|
||||||
return nil, errors.New("ike: UDP transport is closed")
|
return nil, errors.New("ike: UDP transport is closed")
|
||||||
}
|
}
|
||||||
requestHeader, _, err := parseIKEPacket(packet)
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("ike: outbound packet list is empty")
|
||||||
|
}
|
||||||
|
requestHeader, _, err := parseIKEPacket(packets[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
||||||
}
|
}
|
||||||
wirePacket := packet
|
var wirePackets [][]byte
|
||||||
if transport.floated {
|
for _, pkt := range packets {
|
||||||
wirePacket = append([]byte{0, 0, 0, 0}, packet...)
|
wire := pkt
|
||||||
}
|
if transport.floated {
|
||||||
write := func(value []byte) error {
|
wire = append([]byte{0, 0, 0, 0}, pkt...)
|
||||||
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
_, err := transport.conn.Write(value)
|
wirePackets = append(wirePackets, wire)
|
||||||
return err
|
}
|
||||||
|
writeAll := func(values [][]byte) error {
|
||||||
|
for _, value := range values {
|
||||||
|
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := transport.conn.Write(value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||||
for {
|
for {
|
||||||
@@ -252,10 +322,6 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if transport.floated {
|
if transport.floated {
|
||||||
// IKE and ESP legitimately share UDP/4500. An ESP packet can
|
|
||||||
// arrive immediately before the IKE response that completes
|
|
||||||
// CHILD_SA setup; discard it here and keep the same absolute
|
|
||||||
// attempt deadline while waiting for marked IKE.
|
|
||||||
if !hasNonESPMarker(buffer[:n]) {
|
if !hasNonESPMarker(buffer[:n]) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -268,7 +334,7 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roundTripDatagram(ctx, transport.config.Timeout, write, read, wirePacket)
|
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, wirePackets)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *directUDP) SendESP(ctx context.Context, packet []byte) error {
|
func (transport *directUDP) SendESP(ctx context.Context, packet []byte) error {
|
||||||
@@ -561,12 +627,26 @@ func (transport *socks5UDP) Float(_ context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||||
|
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(responses) == 0 {
|
||||||
|
return nil, errors.New("ike: empty exchange response")
|
||||||
|
}
|
||||||
|
return responses[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *socks5UDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
defer transport.mu.Unlock()
|
defer transport.mu.Unlock()
|
||||||
if transport.udp == nil {
|
if transport.udp == nil {
|
||||||
return nil, errors.New("ike: SOCKS5 UDP transport is closed")
|
return nil, errors.New("ike: SOCKS5 UDP transport is closed")
|
||||||
}
|
}
|
||||||
requestHeader, _, err := parseIKEPacket(packet)
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("ike: outbound packet list is empty")
|
||||||
|
}
|
||||||
|
requestHeader, _, err := parseIKEPacket(packets[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
||||||
}
|
}
|
||||||
@@ -576,18 +656,18 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
||||||
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var cookieResponse []byte
|
var cookieResponse [][]byte
|
||||||
for _, candidate := range transport.remotes {
|
for _, candidate := range transport.remotes {
|
||||||
transport.remote = cloneUDPAddr(candidate)
|
transport.remote = cloneUDPAddr(candidate)
|
||||||
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
responses, attemptErr := transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
|
||||||
if attemptErr == nil {
|
if attemptErr == nil {
|
||||||
if ikeInitResponseHasCookie(response) {
|
if len(responses) > 0 && ikeInitResponseHasCookie(responses[0]) {
|
||||||
if cookieResponse == nil {
|
if cookieResponse == nil {
|
||||||
cookieResponse = append([]byte(nil), response...)
|
cookieResponse = responses
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return response, nil
|
return responses, nil
|
||||||
}
|
}
|
||||||
lastErr = attemptErr
|
lastErr = attemptErr
|
||||||
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
|
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
|
||||||
@@ -599,24 +679,32 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
||||||
}
|
}
|
||||||
return transport.roundTripLocked(ctx, packet, requestHeader)
|
return transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte, requestHeader ikeHeader) ([]byte, error) {
|
func (transport *socks5UDP) roundTripFragmentsLocked(ctx context.Context, packets [][]byte, requestHeader ikeHeader) ([][]byte, error) {
|
||||||
wireIKE := packet
|
var datagrams [][]byte
|
||||||
if transport.floated {
|
for _, pkt := range packets {
|
||||||
wireIKE = append([]byte{0, 0, 0, 0}, packet...)
|
wireIKE := pkt
|
||||||
}
|
if transport.floated {
|
||||||
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
wireIKE = append([]byte{0, 0, 0, 0}, pkt...)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
write := func(value []byte) error {
|
|
||||||
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
_, err := transport.udp.Write(value)
|
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
||||||
return err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
datagrams = append(datagrams, datagram)
|
||||||
|
}
|
||||||
|
writeAll := func(values [][]byte) error {
|
||||||
|
for _, value := range values {
|
||||||
|
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := transport.udp.Write(value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||||
for {
|
for {
|
||||||
@@ -630,10 +718,6 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if transport.floated {
|
if transport.floated {
|
||||||
// The relay can deliver ESP before the marked IKE response on
|
|
||||||
// the same UDP/4500 association. Do not accept it as IKE, and
|
|
||||||
// do not abort the exchange; keep waiting within the original
|
|
||||||
// deadline.
|
|
||||||
if !hasNonESPMarker(payload) {
|
if !hasNonESPMarker(payload) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -646,7 +730,7 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
|||||||
return len(payload), nil
|
return len(payload), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roundTripDatagram(ctx, transport.config.Timeout, write, read, datagram)
|
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, datagrams)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNetworkTimeout(err error) bool {
|
func isNetworkTimeout(err error) bool {
|
||||||
|
|||||||
+14
-12
@@ -31,9 +31,10 @@ const (
|
|||||||
payloadDelete = 42
|
payloadDelete = 42
|
||||||
payloadTSi = 44
|
payloadTSi = 44
|
||||||
payloadTSr = 45
|
payloadTSr = 45
|
||||||
payloadEncrypted = 46
|
payloadEncrypted = 46
|
||||||
payloadCP = 47
|
payloadCP = 47
|
||||||
payloadEAP = 48
|
payloadEAP = 48
|
||||||
|
payloadEncryptedFragment = 53
|
||||||
|
|
||||||
protocolIKE = 1
|
protocolIKE = 1
|
||||||
protocolESP = 3
|
protocolESP = 3
|
||||||
@@ -55,15 +56,16 @@ const (
|
|||||||
dhMODP2048 = 14
|
dhMODP2048 = 14
|
||||||
transformAttributeKeyLen = 14
|
transformAttributeKeyLen = 14
|
||||||
|
|
||||||
notifyInitialContact = 16384
|
notifyInitialContact = 16384
|
||||||
notifyMOBIKESupported = 16396
|
notifyMOBIKESupported = 16396
|
||||||
notifyNATSource = 16388
|
notifyNATSource = 16388
|
||||||
notifyNATDestination = 16389
|
notifyNATDestination = 16389
|
||||||
notifyCookie = 16390
|
notifyCookie = 16390
|
||||||
notifyEAPOnlyAuth = 16417
|
notifyEAPOnlyAuth = 16417
|
||||||
notifyDeviceIdentity = 41101
|
notifyFragmentationSupported = 16430
|
||||||
notifyInvalidKE = 17
|
notifyDeviceIdentity = 41101
|
||||||
notifyNoProposal = 14
|
notifyInvalidKE = 17
|
||||||
|
notifyNoProposal = 14
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -169,18 +169,6 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
|
||||||
for _, mnc := range []string{"03", "003"} {
|
|
||||||
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
|
||||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
|
||||||
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
|
||||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
||||||
epdg := payload{
|
epdg := payload{
|
||||||
Type: payloadIDr,
|
Type: payloadIDr,
|
||||||
|
|||||||
@@ -368,6 +368,25 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
|
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
|
||||||
|
calledNumber := identityNumber(request.value("To"))
|
||||||
|
if calledNumber == "" {
|
||||||
|
calledNumber = session.identity.public
|
||||||
|
}
|
||||||
|
receivedCall := ReceivedCall{
|
||||||
|
DeviceID: session.request.DeviceID,
|
||||||
|
IMSI: session.request.Identity.IMSI,
|
||||||
|
CallID: callID,
|
||||||
|
Caller: number,
|
||||||
|
Called: calledNumber,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = session.provider.config.OnIncomingCall(ctx, receivedCall)
|
||||||
|
}()
|
||||||
|
}
|
||||||
response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil)
|
response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = respond(response)
|
_ = respond(response)
|
||||||
|
|||||||
@@ -80,6 +80,53 @@ func TestIncomingCallCanBeRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIncomingCallTriggersOnIncomingCallCallback(t *testing.T) {
|
||||||
|
var captured ReceivedCall
|
||||||
|
called := make(chan struct{}, 1)
|
||||||
|
provider := &Provider{
|
||||||
|
config: Config{
|
||||||
|
OnIncomingCall: func(_ context.Context, call ReceivedCall) error {
|
||||||
|
captured = call
|
||||||
|
called <- struct{}{}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
session := &Session{
|
||||||
|
provider: provider,
|
||||||
|
fromTag: "local-tag",
|
||||||
|
calls: make(map[string]*imsCall),
|
||||||
|
request: vowifi.IMSRequest{
|
||||||
|
DeviceID: "ec20-test",
|
||||||
|
Identity: vowifi.SIMIdentity{IMSI: "123456789012345"},
|
||||||
|
},
|
||||||
|
identity: identitySet{public: "sip:[email protected]"},
|
||||||
|
}
|
||||||
|
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||||
|
"INVITE sip:[email protected] SIP/2.0",
|
||||||
|
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-notify",
|
||||||
|
"From: <tel:+447700999888>;tag=caller-tag",
|
||||||
|
"To: <tel:+447700900123>",
|
||||||
|
"Call-ID: notify-call-id",
|
||||||
|
"CSeq: 1 INVITE",
|
||||||
|
"Content-Length: 0", "", "",
|
||||||
|
}, "\r\n")))
|
||||||
|
if err != nil || packet.Request == nil {
|
||||||
|
t.Fatalf("parse INVITE: %v", err)
|
||||||
|
}
|
||||||
|
session.handleCallRequest(packet.Request, func([]byte) error { return nil })
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-called:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("OnIncomingCall was not invoked within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
if captured.DeviceID != "ec20-test" || captured.Caller != "+447700999888" || captured.Called != "+447700900123" || captured.CallID != "notify-call-id" {
|
||||||
|
t.Fatalf("captured call = %#v", captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
|
func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
|
||||||
session := &Session{calls: make(map[string]*imsCall)}
|
session := &Session{calls: make(map[string]*imsCall)}
|
||||||
call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}}
|
call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}}
|
||||||
@@ -176,13 +223,13 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
|||||||
wire := <-wireResult
|
wire := <-wireResult
|
||||||
|
|
||||||
for _, expected := range []string{
|
for _, expected := range []string{
|
||||||
"INVITE sip:888@ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n",
|
"INVITE tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n",
|
||||||
"To: <sip:888@ims.mnc033.mcc234.3gppnetwork.org>\r\n",
|
"To: <tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org>\r\n",
|
||||||
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
||||||
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||||
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||||
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;country=GB;network-provided\r\n",
|
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided\r\n",
|
||||||
"User-Agent: VoCat Test\r\n",
|
"User-Agent: VoCat Test\r\n",
|
||||||
"Accept: application/sdp\r\n",
|
"Accept: application/sdp\r\n",
|
||||||
} {
|
} {
|
||||||
|
|||||||
+121
-89
@@ -70,11 +70,23 @@ type Config struct {
|
|||||||
// IMS (3GPP TS 24.390). Returning an error is logged but does not affect
|
// IMS (3GPP TS 24.390). Returning an error is logged but does not affect
|
||||||
// the 200 OK already sent, because USSI has no RP-ACK transport.
|
// the 200 OK already sent, because USSI has no RP-ACK transport.
|
||||||
OnUSSD func(context.Context, ReceivedUSSD) error
|
OnUSSD func(context.Context, ReceivedUSSD) error
|
||||||
|
// OnIncomingCall is invoked when an incoming voice call (INVITE) is received over IMS.
|
||||||
|
OnIncomingCall func(context.Context, ReceivedCall) error
|
||||||
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
|
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
|
||||||
// not include message text or raw protocol payloads.
|
// not include message text or raw protocol payloads.
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReceivedCall is an incoming voice call event delivered over IMS.
|
||||||
|
type ReceivedCall struct {
|
||||||
|
DeviceID string
|
||||||
|
IMSI string
|
||||||
|
CallID string
|
||||||
|
Caller string
|
||||||
|
Called string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
|
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
|
||||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||||
// runtime dependency outside the Go standard library.
|
// runtime dependency outside the Go standard library.
|
||||||
@@ -218,99 +230,109 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pcscf := provider.config.PCSCF
|
var pcscfCandidates []string
|
||||||
if pcscf == "" {
|
if provider.config.PCSCF != "" {
|
||||||
|
pcscfCandidates = []string{provider.config.PCSCF}
|
||||||
|
} else {
|
||||||
for _, candidate := range tunnel.PCSCF {
|
for _, candidate := range tunnel.PCSCF {
|
||||||
if strings.TrimSpace(candidate) != "" {
|
candidate = strings.TrimSpace(candidate)
|
||||||
pcscf = candidate
|
if candidate != "" {
|
||||||
break
|
pcscfCandidates = append(pcscfCandidates, candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pcscf == "" {
|
if len(pcscfCandidates) == 0 {
|
||||||
return nil, errors.New("ims: tunnel did not provide a P-CSCF")
|
return nil, errors.New("ims: tunnel did not provide a P-CSCF")
|
||||||
}
|
}
|
||||||
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
|
||||||
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
|
||||||
}
|
|
||||||
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
|
|
||||||
if cached := provider.cachedTransport(request.Identity); cached != "" {
|
|
||||||
transport = cached
|
|
||||||
carrierSelected = true
|
|
||||||
}
|
|
||||||
if transport == "" && !carrierSelected {
|
|
||||||
transport = transportHint
|
|
||||||
}
|
|
||||||
if transport == "" {
|
|
||||||
transport = provider.config.Transport
|
|
||||||
}
|
|
||||||
if transport == "" {
|
|
||||||
transport = "tcp"
|
|
||||||
}
|
|
||||||
localAddress := provider.config.LocalAddress
|
|
||||||
if localAddress == "" {
|
|
||||||
if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil {
|
|
||||||
localAddress = tunnel.LocalIPv6
|
|
||||||
} else {
|
|
||||||
localAddress = tunnel.LocalIPv4
|
|
||||||
if strings.TrimSpace(localAddress) == "" {
|
|
||||||
localAddress = tunnel.LocalIPv6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0])
|
|
||||||
if localAddress == "" {
|
|
||||||
return nil, errors.New("ims: tunnel did not provide a local address")
|
|
||||||
}
|
|
||||||
if !localAddressProvenByTunnel(localAddress, tunnel) {
|
|
||||||
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
|
||||||
}
|
|
||||||
|
|
||||||
transports := []string{transport}
|
|
||||||
if provider.config.AutoTransportFallback {
|
|
||||||
alternate := "udp"
|
|
||||||
if transport == "udp" {
|
|
||||||
alternate = "tcp"
|
|
||||||
}
|
|
||||||
transports = append(transports, alternate)
|
|
||||||
}
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt, candidate := range transports {
|
for pcscfIndex, pcscf := range pcscfCandidates {
|
||||||
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
|
||||||
if dialErr != nil {
|
if err != nil {
|
||||||
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
lastErr = err
|
||||||
if attempt+1 < len(transports) && ctx.Err() == nil {
|
continue
|
||||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
}
|
||||||
continue
|
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
||||||
|
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||||
|
}
|
||||||
|
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
|
||||||
|
if cached := provider.cachedTransport(request.Identity); cached != "" {
|
||||||
|
transport = cached
|
||||||
|
carrierSelected = true
|
||||||
|
}
|
||||||
|
if transport == "" && !carrierSelected {
|
||||||
|
transport = transportHint
|
||||||
|
}
|
||||||
|
if transport == "" {
|
||||||
|
transport = provider.config.Transport
|
||||||
|
}
|
||||||
|
if transport == "" {
|
||||||
|
transport = "tcp"
|
||||||
|
}
|
||||||
|
localAddress := provider.config.LocalAddress
|
||||||
|
if localAddress == "" {
|
||||||
|
if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil {
|
||||||
|
localAddress = tunnel.LocalIPv6
|
||||||
|
} else {
|
||||||
|
localAddress = tunnel.LocalIPv4
|
||||||
|
if strings.TrimSpace(localAddress) == "" {
|
||||||
|
localAddress = tunnel.LocalIPv6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
|
||||||
}
|
}
|
||||||
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0])
|
||||||
if sessionErr != nil {
|
if localAddress == "" {
|
||||||
_ = connection.Close()
|
return nil, errors.New("ims: tunnel did not provide a local address")
|
||||||
return nil, sessionErr
|
|
||||||
}
|
}
|
||||||
establishErr := session.establish(ctx)
|
if !localAddressProvenByTunnel(localAddress, tunnel) {
|
||||||
if establishErr == nil {
|
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
||||||
provider.rememberTransport(request.Identity, candidate)
|
}
|
||||||
if attempt > 0 {
|
|
||||||
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
transports := []string{transport}
|
||||||
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
if provider.config.AutoTransportFallback {
|
||||||
"transport", candidate)
|
alternate := "udp"
|
||||||
|
if transport == "udp" {
|
||||||
|
alternate = "tcp"
|
||||||
}
|
}
|
||||||
return session, nil
|
transports = append(transports, alternate)
|
||||||
}
|
}
|
||||||
sipResponseObserved := session.evidence.LastSIPCode != 0
|
for attempt, candidate := range transports {
|
||||||
session.abort()
|
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||||
lastErr = establishErr
|
if dialErr != nil {
|
||||||
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
||||||
return nil, lastErr
|
if attempt+1 < len(transports) && ctx.Err() == nil {
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||||
|
if sessionErr != nil {
|
||||||
|
_ = connection.Close()
|
||||||
|
lastErr = sessionErr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
establishErr := session.establish(ctx)
|
||||||
|
if establishErr == nil {
|
||||||
|
provider.rememberTransport(request.Identity, candidate)
|
||||||
|
if attempt > 0 || pcscfIndex > 0 {
|
||||||
|
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
||||||
|
"transport", candidate)
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
sipResponseObserved := session.evidence.LastSIPCode != 0
|
||||||
|
session.abort()
|
||||||
|
lastErr = establishErr
|
||||||
|
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
@@ -372,8 +394,13 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
|||||||
if !digitsBetween(imsi, 5, 16) {
|
if !digitsBetween(imsi, 5, 16) {
|
||||||
return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid")
|
return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid")
|
||||||
}
|
}
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
mcc := strings.TrimSpace(profile.RouteMCC)
|
||||||
|
mnc := strings.TrimSpace(profile.RouteMNC)
|
||||||
|
if mcc == "" || mnc == "" {
|
||||||
|
mcc = strings.TrimSpace(identity.HomeMCC)
|
||||||
|
mnc = strings.TrimSpace(identity.HomeMNC)
|
||||||
|
}
|
||||||
if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) {
|
if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) {
|
||||||
return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid")
|
return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid")
|
||||||
}
|
}
|
||||||
@@ -383,7 +410,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
|||||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||||
privateDomain := domain
|
privateDomain := domain
|
||||||
publicDomain := domain
|
publicDomain := domain
|
||||||
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
|
if profile.IMSIdentityProfile == vowifi.IMSProfileATT {
|
||||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||||
// the generic 3GPP PLMN IMS domain.
|
// the generic 3GPP PLMN IMS domain.
|
||||||
domain = "one.att.net"
|
domain = "one.att.net"
|
||||||
@@ -964,19 +991,17 @@ func (session *Session) buildRegister(
|
|||||||
}
|
}
|
||||||
lines = append(lines, "User-Agent: "+userAgent)
|
lines = append(lines, "User-Agent: "+userAgent)
|
||||||
|
|
||||||
defaultPANI := "IEEE-802.11;i-wlan-node-id=000000000000;network-provided"
|
|
||||||
pani := defaultPANI
|
|
||||||
if registerOptions.PAccessNetworkInfo != nil {
|
|
||||||
pani = *registerOptions.PAccessNetworkInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
if registerOptions.PPreferredIdentity {
|
if registerOptions.PPreferredIdentity {
|
||||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
||||||
lines = append(lines, `P-Visited-Network-ID: "`+value+`"`)
|
lines = append(lines, `P-Visited-Network-ID: "`+value+`"`)
|
||||||
}
|
}
|
||||||
if pani != "" {
|
// PANI carries access/location information and must not be fabricated.
|
||||||
|
// In particular, "network-provided" identifies a value inserted by a
|
||||||
|
// trusted network proxy, not one generated by this UE. Send the header
|
||||||
|
// only when a carrier profile explicitly supplies a reviewed value.
|
||||||
|
if pani := optionalRegisterHeader(registerOptions.PAccessNetworkInfo); pani != "" {
|
||||||
lines = append(lines, "P-Access-Network-Info: "+pani)
|
lines = append(lines, "P-Access-Network-Info: "+pani)
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
||||||
@@ -1020,6 +1045,13 @@ func (session *Session) buildRegister(
|
|||||||
return []byte(strings.Join(lines, "\r\n")), nil
|
return []byte(strings.Join(lines, "\r\n")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalRegisterHeader(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*value)
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) buildContact(contactAddress string, registerOptions vowifi.IMSRegisterOptions) string {
|
func (session *Session) buildContact(contactAddress string, registerOptions vowifi.IMSRegisterOptions) string {
|
||||||
base := fmt.Sprintf("<sip:%s@%s;transport=%s>", session.identity.user, contactAddress, session.transport)
|
base := fmt.Sprintf("<sip:%s@%s;transport=%s>", session.identity.user, contactAddress, session.transport)
|
||||||
instanceID := session.instanceID
|
instanceID := session.instanceID
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
|||||||
|
|
||||||
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
|
||||||
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "tcp" {
|
||||||
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
t.Fatalf("standard transport = %q, want tcp", got)
|
||||||
}
|
}
|
||||||
if got := transportForIdentity(Config{
|
if got := transportForIdentity(Config{
|
||||||
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
Transport: "udp", TransportByPLMN: map[string]string{"99999": "tcp"},
|
||||||
}, identity); got != "tcp" {
|
}, identity); got != "tcp" {
|
||||||
t.Fatalf("explicit configuration did not override profile: %q", got)
|
t.Fatalf("explicit configuration did not override: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +377,7 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
for _, forbidden := range []string{
|
for _, forbidden := range []string{
|
||||||
"p-visited-network-id",
|
"p-visited-network-id",
|
||||||
"p-preferred-identity",
|
"p-preferred-identity",
|
||||||
|
"p-access-network-info",
|
||||||
} {
|
} {
|
||||||
if headers[forbidden] != "" {
|
if headers[forbidden] != "" {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
@@ -386,9 +387,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
|
|
||||||
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
|
|
||||||
}
|
|
||||||
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||||
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||||
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
||||||
@@ -497,135 +495,23 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
func TestOptionalRegisterHeaderRequiresExplicitNonemptyValue(t *testing.T) {
|
||||||
client, server := net.Pipe()
|
explicit := " IEEE-802.11;i-wlan-node-id=aabbccddeeff "
|
||||||
defer client.Close()
|
empty := " "
|
||||||
defer server.Close()
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
identity := vowifi.SIMIdentity{
|
value *string
|
||||||
IMSI: "262030123456789",
|
want string
|
||||||
HomeMCC: "262",
|
}{
|
||||||
HomeMNC: "03",
|
{name: "unspecified", value: nil, want: ""},
|
||||||
}
|
{name: "explicit omission", value: &empty, want: ""},
|
||||||
identities, err := deriveIdentities(identity, Config{})
|
{name: "explicit value", value: &explicit, want: "IEEE-802.11;i-wlan-node-id=aabbccddeeff"},
|
||||||
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.Run(test.name, func(t *testing.T) {
|
||||||
t.Fatalf("%s = %q, want token %q", name, headers[name], token)
|
if got := optionalRegisterHeader(test.value); got != test.want {
|
||||||
}
|
t.Fatalf("optionalRegisterHeader() = %q, want %q", got, test.want)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,16 @@ func (media *rtpMedia) ready() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (media *rtpMedia) offerSDP(local net.IP) []byte {
|
func (media *rtpMedia) offerSDP(local net.IP) []byte {
|
||||||
return media.buildSDP(local, "8 0", nil)
|
return media.buildSDP(local, "8 0 104 102 100", []string{
|
||||||
|
"a=rtpmap:8 PCMA/8000",
|
||||||
|
"a=rtpmap:0 PCMU/8000",
|
||||||
|
"a=rtpmap:104 AMR-WB/16000",
|
||||||
|
"a=fmtp:104 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:102 AMR/8000",
|
||||||
|
"a=fmtp:102 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:100 telephone-event/8000",
|
||||||
|
"a=fmtp:100 0-15",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
||||||
@@ -81,8 +90,12 @@ func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
|||||||
if codec == "" {
|
if codec == "" {
|
||||||
return media.offerSDP(local)
|
return media.offerSDP(local)
|
||||||
}
|
}
|
||||||
|
rate := 8000
|
||||||
|
if codec == "AMR-WB" {
|
||||||
|
rate = 16000
|
||||||
|
}
|
||||||
return media.buildSDP(local, strconv.Itoa(int(payload)), []string{
|
return media.buildSDP(local, strconv.Itoa(int(payload)), []string{
|
||||||
fmt.Sprintf("a=rtpmap:%d %s/8000", payload, codec),
|
fmt.Sprintf("a=rtpmap:%d %s/%d", payload, codec, rate),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +123,16 @@ func (media *rtpMedia) buildSDP(local net.IP, formats string, attributes []strin
|
|||||||
fmt.Sprintf("m=audio %d RTP/AVP %s", port, formats),
|
fmt.Sprintf("m=audio %d RTP/AVP %s", port, formats),
|
||||||
}
|
}
|
||||||
if attributes == nil {
|
if attributes == nil {
|
||||||
lines = append(lines, "a=rtpmap:8 PCMA/8000", "a=rtpmap:0 PCMU/8000")
|
lines = append(lines,
|
||||||
|
"a=rtpmap:8 PCMA/8000",
|
||||||
|
"a=rtpmap:0 PCMU/8000",
|
||||||
|
"a=rtpmap:104 AMR-WB/16000",
|
||||||
|
"a=fmtp:104 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:102 AMR/8000",
|
||||||
|
"a=fmtp:102 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:100 telephone-event/8000",
|
||||||
|
"a=fmtp:100 0-15",
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, attributes...)
|
lines = append(lines, attributes...)
|
||||||
}
|
}
|
||||||
@@ -137,15 +159,24 @@ func (media *rtpMedia) configureRemote(body []byte) error {
|
|||||||
name = "PCMU"
|
name = "PCMU"
|
||||||
case 8:
|
case 8:
|
||||||
name = "PCMA"
|
name = "PCMA"
|
||||||
|
case 100:
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
name = fmt.Sprintf("PAYLOAD-%d", parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if name == "PCMA" || name == "PCMU" {
|
if name != "TELEPHONE-EVENT" {
|
||||||
codec, payload = name, byte(parsed)
|
codec, payload = name, byte(parsed)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if codec == "" && len(formats) > 0 {
|
||||||
|
if parsed, parseErr := strconv.Atoi(formats[0]); parseErr == nil {
|
||||||
|
codec, payload = fmt.Sprintf("PAYLOAD-%d", parsed), byte(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
if codec == "" {
|
if codec == "" {
|
||||||
return errors.New("ims: remote endpoint did not accept PCMA or PCMU audio")
|
return errors.New("ims: remote SDP has no usable audio format")
|
||||||
}
|
}
|
||||||
media.mu.Lock()
|
media.mu.Lock()
|
||||||
media.remote = &net.UDPAddr{IP: address, Port: port}
|
media.remote = &net.UDPAddr{IP: address, Port: port}
|
||||||
|
|||||||
@@ -38,28 +38,18 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) {
|
func TestSecurityProposalDefaultUsesAESCBC(t *testing.T) {
|
||||||
identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"}
|
identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
|
||||||
if got := securityEncryptionForIdentity(identity); got != "null" {
|
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
||||||
t.Fatalf("O2 security encryption = %q, want null", got)
|
t.Fatalf("standard security encryption = %q, want aes-cbc", got)
|
||||||
}
|
}
|
||||||
proposal := securityProposal{
|
proposal := securityProposal{
|
||||||
spiClient: 1001, spiServer: 1002,
|
spiClient: 1001, spiServer: 1002,
|
||||||
portClient: 40666, portServer: 55610,
|
portClient: 40666, portServer: 55610,
|
||||||
encryption: securityEncryptionForIdentity(identity),
|
encryption: securityEncryptionForIdentity(identity),
|
||||||
}
|
}
|
||||||
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
||||||
t.Fatalf("O2 Security-Client = %q, want %q", got, want)
|
t.Fatalf("Security-Client = %q, want %q", got, want)
|
||||||
}
|
|
||||||
selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
|
||||||
"ealg=null;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
|
||||||
if _, err := parseSecurityAgreement([]string{selected}, proposal); err != nil {
|
|
||||||
t.Fatalf("O2 null Security-Server rejected: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
identity.HomeMNC = "02"
|
|
||||||
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
|
||||||
t.Fatalf("non-O2 security encryption = %q, want aes-cbc", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ type ReceivedSMS struct {
|
|||||||
RawTPDU string
|
RawTPDU string
|
||||||
DecodeError string
|
DecodeError string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
|
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
|
||||||
type ReceivedSMSStatus struct {
|
type ReceivedSMSStatus struct {
|
||||||
DeviceID string
|
DeviceID string
|
||||||
@@ -891,10 +890,8 @@ func (session *Session) parseUSSIReply(response *sipResponse) (string, *int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) ussiTarget() string {
|
func (session *Session) ussiTarget() string {
|
||||||
if number, _, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok {
|
if domain := strings.TrimSpace(session.identity.domain); domain != "" {
|
||||||
if normalized := normalizeE164(number); normalized != "" {
|
return "sip:" + domain
|
||||||
return "tel:" + normalized
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return session.identity.public
|
return session.identity.public
|
||||||
}
|
}
|
||||||
@@ -1063,6 +1060,9 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||||
|
if identitySMSC := strings.TrimSpace(identity.SMSC); identitySMSC != "" {
|
||||||
|
return identitySMSC
|
||||||
|
}
|
||||||
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
||||||
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||||
return configured
|
return configured
|
||||||
|
|||||||
@@ -233,18 +233,19 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||||
for _, test := range []struct {
|
// Explicit SIM SMSC always takes precedence
|
||||||
mnc string
|
explicit := smsCenterForIdentity(Config{}, vowifi.SIMIdentity{
|
||||||
want string
|
HomeMCC: "234", HomeMNC: "15", SMSC: "+447785016005",
|
||||||
}{
|
})
|
||||||
{mnc: "10", want: "+447802000332"},
|
if explicit != "+447785016005" {
|
||||||
{mnc: "15", want: "+447785016005"},
|
t.Fatalf("explicit SMSC = %q, want +447785016005", explicit)
|
||||||
{mnc: "30", want: ""},
|
}
|
||||||
} {
|
|
||||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
// Profile fallback when identity has no SMSC
|
||||||
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
|
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||||
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
}
|
if got := smsCenterForIdentity(Config{}, identity); got != profile.SMSCenter {
|
||||||
|
t.Errorf("smsCenterForIdentity = %q, want %q", got, profile.SMSCenter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,12 +765,13 @@ func TestSessionReceivesMalformedSMSBestEffort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionAllowsSMSWithoutContactConfirmationWhenProfilePermits(t *testing.T) {
|
func TestSessionAllowsSMSWhenContactConfirmed(t *testing.T) {
|
||||||
session := &Session{
|
session := &Session{
|
||||||
provider: &Provider{config: Config{Logger: slog.Default()}},
|
provider: &Provider{config: Config{Logger: slog.Default()}},
|
||||||
request: vowifi.IMSRequest{
|
request: vowifi.IMSRequest{
|
||||||
Identity: vowifi.SIMIdentity{HomeMCC: "515", HomeMNC: "66"},
|
Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"},
|
||||||
},
|
},
|
||||||
|
smsContactConfirmed: true,
|
||||||
evidence: vowifi.IMSEvidence{
|
evidence: vowifi.IMSEvidence{
|
||||||
Registered: true,
|
Registered: true,
|
||||||
RegistrationState: "registered",
|
RegistrationState: "registered",
|
||||||
@@ -779,7 +781,7 @@ func TestSessionAllowsSMSWithoutContactConfirmationWhenProfilePermits(t *testing
|
|||||||
|
|
||||||
evidence, err := session.EnableSMS(context.Background())
|
evidence, err := session.EnableSMS(context.Background())
|
||||||
if err != nil || !evidence.Ready {
|
if err != nil || !evidence.Ready {
|
||||||
t.Fatalf("EnableSMS() = (%#v, %v), want ready for DITO profile", evidence, err)
|
t.Fatalf("EnableSMS() = (%#v, %v), want ready when contact confirmed", evidence, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20",
|
DeviceID: "ec20",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
ProfileName: "Vodafone UK",
|
ProfileName: "Vodafone UK",
|
||||||
UpstreamProxyID: "clash",
|
UpstreamProxyID: "clash",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -46,7 +46,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(
|
route, err := (ProxyResolver{Store: database}).Resolve(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", HomeMNC: "15"},
|
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234", HomeMNC: "15"},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -67,7 +67,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
|
|||||||
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
|
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
|
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
|
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
|
||||||
@@ -137,12 +137,12 @@ func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
||||||
@@ -169,7 +169,7 @@ func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
request := vowifi.ProxyRequest{
|
request := vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
}
|
}
|
||||||
resolver := ProxyResolver{Store: database}
|
resolver := ProxyResolver{Store: database}
|
||||||
route, err := resolver.Resolve(ctx, request)
|
route, err := resolver.Resolve(ctx, request)
|
||||||
@@ -214,7 +214,7 @@ func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testin
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
iccid := "89441000400128014257"
|
iccid := "8944100000000000001"
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -257,12 +257,12 @@ func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
|
DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -400,10 +400,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if err := projector.Save(context.Background(), vowifi.State{
|
if err := projector.Save(context.Background(), vowifi.State{
|
||||||
DeviceID: "ec20",
|
DeviceID: "ec20",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
IMSI: "234159608751160",
|
IMSI: "234150000000001",
|
||||||
Phase: vowifi.PhaseStopping,
|
Phase: vowifi.PhaseStopping,
|
||||||
PhoneNumber: "+447386083638",
|
PhoneNumber: "+447700900123",
|
||||||
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
|
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -413,10 +413,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if runtime.ICCID != "89441000400128014257" || runtime.IMSI != "234159608751160" {
|
if runtime.ICCID != "8944100000000000001" || runtime.IMSI != "234150000000001" {
|
||||||
t.Fatalf("runtime identity = %q/%q", runtime.ICCID, runtime.IMSI)
|
t.Fatalf("runtime identity = %q/%q", runtime.ICCID, runtime.IMSI)
|
||||||
}
|
}
|
||||||
if runtime.LocalPhone != "+447386083638" {
|
if runtime.LocalPhone != "+447700900123" {
|
||||||
t.Fatalf("runtime phone = %q", runtime.LocalPhone)
|
t.Fatalf("runtime phone = %q", runtime.LocalPhone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,13 +120,13 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
|
|||||||
name: "three digit MNC is preserved",
|
name: "three digit MNC is preserved",
|
||||||
identity: SIMIdentity{
|
identity: SIMIdentity{
|
||||||
ICCID: "one",
|
ICCID: "one",
|
||||||
HomeMCC: "310",
|
HomeMCC: "999",
|
||||||
HomeMNC: "260",
|
HomeMNC: "260",
|
||||||
},
|
},
|
||||||
want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org",
|
want: "epdg.epc.mnc260.mcc999.pub.3gppnetwork.org",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AT&T 310280 uses carrier endpoint",
|
name: "AT&T 310280 uses carrier bundle ePDG",
|
||||||
identity: SIMIdentity{
|
identity: SIMIdentity{
|
||||||
ICCID: "8901000000000000001",
|
ICCID: "8901000000000000001",
|
||||||
IMSI: "310280000000001",
|
IMSI: "310280000000001",
|
||||||
|
|||||||
+23
-7
@@ -203,9 +203,14 @@ install_qmi_support() {
|
|||||||
|
|
||||||
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
||||||
opkg update >/dev/null 2>&1 || true
|
opkg update >/dev/null 2>&1 || true
|
||||||
if opkg_has_package libqmi; then
|
local pkgs=""
|
||||||
opkg install libqmi >/dev/null 2>&1 || true
|
opkg_has_package qmi-utils && pkgs="$pkgs qmi-utils"
|
||||||
|
opkg_has_package libqmi && pkgs="$pkgs libqmi"
|
||||||
|
if [ -z "$pkgs" ]; then
|
||||||
|
pkgs="qmi-utils libqmi"
|
||||||
fi
|
fi
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
opkg install $pkgs >/dev/null 2>&1 || true
|
||||||
elif command -v apt-get >/dev/null 2>&1; then
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
apt-get update -qq || true
|
apt-get update -qq || true
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true
|
DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true
|
||||||
@@ -236,6 +241,7 @@ install_pcsc_support() {
|
|||||||
local packages=""
|
local packages=""
|
||||||
opkg_has_package pcscd && packages="$packages pcscd"
|
opkg_has_package pcscd && packages="$packages pcscd"
|
||||||
opkg_has_package ccid && packages="$packages ccid"
|
opkg_has_package ccid && packages="$packages ccid"
|
||||||
|
opkg_has_package libccid && packages="$packages libccid"
|
||||||
if [ -n "$packages" ]; then
|
if [ -n "$packages" ]; then
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
opkg install $packages >/dev/null 2>&1 && installed=1 || true
|
opkg install $packages >/dev/null 2>&1 && installed=1 || true
|
||||||
@@ -363,7 +369,7 @@ download_and_verify() {
|
|||||||
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
|
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
|
||||||
chmod 0755 "${VOCAT_TMP}/vocat"
|
chmod 0755 "${VOCAT_TMP}/vocat"
|
||||||
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
|
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
|
||||||
"Downloaded binary cannot run on this system; keeping the installed version." \
|
"下载的二进制文件无法在此系统上运行;未更改当前安装的版本。" \
|
||||||
"The downloaded binary cannot run on this host; the installed version was not changed."
|
"The downloaded binary cannot run on this host; the installed version was not changed."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,8 +398,18 @@ INITIAL_ADMIN_PASSWORD=""
|
|||||||
bootstrap_admin() {
|
bootstrap_admin() {
|
||||||
local candidate="${1:-$BINARY_PATH}"
|
local candidate="${1:-$BINARY_PATH}"
|
||||||
local secret result
|
local secret result
|
||||||
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
if command -v od >/dev/null 2>&1; then
|
||||||
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
|
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||||
|
elif command -v hexdump >/dev/null 2>&1; then
|
||||||
|
secret=$(hexdump -n 16 -e '16/1 "%02x"' /dev/urandom)
|
||||||
|
elif command -v openssl >/dev/null 2>&1; then
|
||||||
|
secret=$(openssl rand -hex 16 2>/dev/null || true)
|
||||||
|
elif command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
secret=$(head -c 32 /dev/urandom | sha256sum | awk '{print substr($1, 1, 32)}')
|
||||||
|
else
|
||||||
|
secret=$(tr -dc 'a-f0-9' < /dev/urandom | head -c 32)
|
||||||
|
fi
|
||||||
|
[ -n "$secret" ] || die "生成随机密钥失败。" "Failed to generate a random secret."
|
||||||
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||||
die \
|
die \
|
||||||
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
|
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
|
||||||
@@ -505,7 +521,7 @@ write_service() {
|
|||||||
write_openwrt_init
|
write_openwrt_init
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected."
|
die "不支持的服务管理器。" "Neither systemd nor OpenWrt procd was detected."
|
||||||
}
|
}
|
||||||
|
|
||||||
enable_and_start() {
|
enable_and_start() {
|
||||||
@@ -547,7 +563,7 @@ enable_and_start() {
|
|||||||
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
||||||
"$OPENWRT_INIT_PATH" restart || true
|
"$OPENWRT_INIT_PATH" restart || true
|
||||||
fi
|
fi
|
||||||
die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start."
|
die "OpenWrt vocat 服务启动失败。" "The OpenWrt vocat service failed to start."
|
||||||
fi
|
fi
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable vocat
|
systemctl enable vocat
|
||||||
|
|||||||
@@ -1,55 +1,237 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
||||||
import { useI18n, tf } from "../../lib/i18n";
|
import { useI18n, tf } from "../../lib/i18n";
|
||||||
import { cx } from "../../lib/utils";
|
import { cx } from "../../lib/utils";
|
||||||
|
|
||||||
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
|
interface DayUptime {
|
||||||
type RateLevel = "green" | "yellow" | "orange" | "red";
|
dateKey: string; // YYYY-MM-DD
|
||||||
|
date: Date;
|
||||||
function rateLevel(percent: number): RateLevel {
|
isToday: boolean;
|
||||||
if (percent >= 100) return "green";
|
daysAgo: number;
|
||||||
if (percent >= 80) return "yellow";
|
uptimePercent: number; // 0 - 100
|
||||||
if (percent >= 50) return "orange";
|
status: "online" | "degraded" | "down" | "none";
|
||||||
return "red";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
|
const STORAGE_KEY = "vocat_uptime_history_14d";
|
||||||
green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" },
|
|
||||||
yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" },
|
function get14DaysSlots(currentOnline: number, currentTotal: number): DayUptime[] {
|
||||||
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
|
let savedMap: Record<string, number> = {};
|
||||||
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
|
try {
|
||||||
};
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) savedMap = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const slots: DayUptime[] = [];
|
||||||
|
|
||||||
|
for (let i = 13; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
const dateKey = `${y}-${m}-${day}`;
|
||||||
|
const isToday = i === 0;
|
||||||
|
|
||||||
|
let percent = 100;
|
||||||
|
if (isToday) {
|
||||||
|
if (currentTotal === 0) {
|
||||||
|
percent = -1;
|
||||||
|
} else {
|
||||||
|
percent = Math.round((currentOnline / currentTotal) * 100);
|
||||||
|
}
|
||||||
|
if (percent >= 0) {
|
||||||
|
savedMap[dateKey] = percent;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dateKey in savedMap) {
|
||||||
|
percent = savedMap[dateKey];
|
||||||
|
} else {
|
||||||
|
percent = currentTotal > 0 ? 100 : -1;
|
||||||
|
if (percent >= 0) savedMap[dateKey] = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: DayUptime["status"] = "online";
|
||||||
|
if (percent < 0) status = "none";
|
||||||
|
else if (percent >= 99) status = "online";
|
||||||
|
else if (percent >= 50) status = "degraded";
|
||||||
|
else status = "down";
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
dateKey,
|
||||||
|
date: d,
|
||||||
|
isToday,
|
||||||
|
daysAgo: i,
|
||||||
|
uptimePercent: percent < 0 ? 0 : percent,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedMap));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。
|
|
||||||
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
||||||
const { t } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const percent = total > 0 ? Math.round((online / total) * 100) : null;
|
const [hoveredDay, setHoveredDay] = useState<DayUptime | null>(null);
|
||||||
const level = percent === null ? null : rateLevel(percent);
|
const [slots, setSlots] = useState<DayUptime[]>(() => get14DaysSlots(online, total));
|
||||||
const styles = level ? LEVEL_STYLES[level] : null;
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSlots(get14DaysSlots(online, total));
|
||||||
|
}, [online, total]);
|
||||||
|
|
||||||
|
const currentPercent = total > 0 ? Math.round((online / total) * 100) : null;
|
||||||
|
const overallAvg =
|
||||||
|
slots.filter((s) => s.status !== "none").length > 0
|
||||||
|
? Math.round(
|
||||||
|
slots.filter((s) => s.status !== "none").reduce((acc, s) => acc + s.uptimePercent, 0) /
|
||||||
|
slots.filter((s) => s.status !== "none").length,
|
||||||
|
)
|
||||||
|
: currentPercent;
|
||||||
|
|
||||||
|
const formatDateLabel = (d: Date) => {
|
||||||
|
if (lang === "zh") {
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-panel p-4">
|
<div className="ui-panel relative flex flex-col justify-between p-4 transition-all">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
{/* Header */}
|
||||||
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
|
<div>
|
||||||
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
|
||||||
{percent === null ? (
|
<PlugConnectedRegular className="h-3.5 w-3.5" />
|
||||||
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-1.5">
|
||||||
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
||||||
{percent}
|
<span className="rounded px-1.5 py-0.2 text-[10px] font-semibold bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||||
<span className="text-2xl">%</span>
|
14d
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="flex items-baseline gap-1">
|
||||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
{overallAvg === null ? (
|
||||||
{styles ? (
|
<span className="text-xl font-extrabold text-gray-400">--%</span>
|
||||||
<span className="flex items-center gap-1">
|
) : (
|
||||||
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
|
<span
|
||||||
{t(styles.labelKey)}
|
className={cx(
|
||||||
|
"text-xl font-extrabold tabular-nums tracking-tight",
|
||||||
|
overallAvg >= 99
|
||||||
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
|
: overallAvg >= 80
|
||||||
|
? "text-yellow-600 dark:text-yellow-400"
|
||||||
|
: "text-red-600 dark:text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{overallAvg}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitle count */}
|
||||||
|
<div className="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"inline-block h-1.5 w-1.5 rounded-full",
|
||||||
|
online > 0 ? "bg-emerald-500 animate-pulse" : "bg-gray-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{tf("{online}/{total} 台在线", { online, total })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
{currentPercent !== null && currentPercent >= 99 ? t("运行优秀") : t("正常监控")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
|
</div>
|
||||||
|
|
||||||
|
{/* Uptime Kuma 14-day Heartbeat Bars */}
|
||||||
|
<div className="my-2.5">
|
||||||
|
<div className="flex items-center gap-1 sm:gap-1.5 h-8 w-full">
|
||||||
|
{slots.map((slot) => {
|
||||||
|
let barBg = "bg-gray-200 dark:bg-white/10";
|
||||||
|
if (slot.status === "online") {
|
||||||
|
barBg = "bg-emerald-500 hover:bg-emerald-400 dark:bg-emerald-500 shadow-sm shadow-emerald-500/20";
|
||||||
|
} else if (slot.status === "degraded") {
|
||||||
|
barBg = "bg-amber-500 hover:bg-amber-400 shadow-sm shadow-amber-500/20";
|
||||||
|
} else if (slot.status === "down") {
|
||||||
|
barBg = "bg-rose-500 hover:bg-rose-400 shadow-sm shadow-rose-500/20";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot.dateKey}
|
||||||
|
onMouseEnter={() => setHoveredDay(slot)}
|
||||||
|
onMouseLeave={() => setHoveredDay(null)}
|
||||||
|
className="group/bar relative flex-1 h-full flex items-end cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"w-full rounded-sm transition-all duration-150 group-hover/bar:scale-y-110",
|
||||||
|
slot.isToday ? "h-full ring-1 ring-emerald-400/40" : "h-full",
|
||||||
|
barBg,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Tooltip on Hover */}
|
||||||
|
{hoveredDay?.dateKey === slot.dateKey && (
|
||||||
|
<div className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-30 whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-[11px] font-medium text-white shadow-xl dark:bg-gray-800 border border-white/10">
|
||||||
|
<div className="font-bold flex items-center gap-1.5">
|
||||||
|
<span>{formatDateLabel(slot.date)}</span>
|
||||||
|
{slot.isToday ? (
|
||||||
|
<span className="rounded bg-emerald-500/30 px-1 text-[9px] text-emerald-300 font-normal">
|
||||||
|
{t("今天")}
|
||||||
|
</span>
|
||||||
|
) : slot.daysAgo === 1 ? (
|
||||||
|
<span className="text-[10px] text-gray-400 font-normal">
|
||||||
|
{t("昨天")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-400 font-normal">
|
||||||
|
{tf("{days}天前", { days: slot.daysAgo })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-gray-300">
|
||||||
|
<span>
|
||||||
|
{slot.status === "online"
|
||||||
|
? `🟢 ${slot.uptimePercent}% ${t("正常在线")}`
|
||||||
|
: slot.status === "degraded"
|
||||||
|
? `🟡 ${slot.uptimePercent}% ${t("部分离线")}`
|
||||||
|
: slot.status === "down"
|
||||||
|
? `🔴 0% ${t("完全离线")}`
|
||||||
|
: `⚪ ${t("暂无数据")}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Tooltip triangle */}
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900 dark:border-t-gray-800" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend / Range labels */}
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[10px] font-medium text-gray-400 dark:text-gray-500">
|
||||||
|
<span>{t("14天前")}</span>
|
||||||
|
<span className="opacity-75">{t("持续监测中")}</span>
|
||||||
|
<span>{t("今天")}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -98,7 +98,12 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||||
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
<FieldRow
|
||||||
|
label={t("运营商配置")}
|
||||||
|
value={!rt?.carrierProfile || rt.carrierProfile === "standard-3gpp" ? "3GPP Standard" : rt.carrierProfile}
|
||||||
|
monospace
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||||
|
|||||||
@@ -948,6 +948,7 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"通知重试发送失败": "Notification resend failed",
|
"通知重试发送失败": "Notification resend failed",
|
||||||
"通知重试发送成功": "Notification resent",
|
"通知重试发送成功": "Notification resent",
|
||||||
"配置存储在数据库中,部分字段可能需要重启生效": "Configuration is stored in the database; some fields may require a restart to take effect",
|
"配置存储在数据库中,部分字段可能需要重启生效": "Configuration is stored in the database; some fields may require a restart to take effect",
|
||||||
|
"配置策略": "Profile Policy",
|
||||||
"配置已保存,但部分变更需要重启服务后生效": "Configuration saved, but some changes require a service restart",
|
"配置已保存,但部分变更需要重启服务后生效": "Configuration saved, but some changes require a service restart",
|
||||||
"采样中断": "Sampling interrupted",
|
"采样中断": "Sampling interrupted",
|
||||||
"重启中": "Rebooting",
|
"重启中": "Rebooting",
|
||||||
@@ -1174,4 +1175,31 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"绑定到该代理的国家规则将自动删除,相关国家会恢复直连。":
|
"绑定到该代理的国家规则将自动删除,相关国家会恢复直连。":
|
||||||
"Country rules bound to this proxy will be deleted, and those countries will revert to a direct connection.",
|
"Country rules bound to this proxy will be deleted, and those countries will revert to a direct connection.",
|
||||||
"{encoding} · 预计 {parts} 段 · {length} 字": "{encoding} · ~{parts} seg · {length} chars",
|
"{encoding} · 预计 {parts} 段 · {length} 字": "{encoding} · ~{parts} seg · {length} chars",
|
||||||
|
|
||||||
|
// Uptime & Monitoring translations
|
||||||
|
"运行优秀": "Excellent",
|
||||||
|
"正常监控": "Optimal",
|
||||||
|
"14天前": "14d ago",
|
||||||
|
"持续监测中": "Monitored",
|
||||||
|
"今天": "Today",
|
||||||
|
"昨天": "Yesterday",
|
||||||
|
"天前": "days ago",
|
||||||
|
"{days}天前": "{days}d ago",
|
||||||
|
"正常在线": "Online",
|
||||||
|
"部分离线": "Degraded",
|
||||||
|
"完全离线": "Offline",
|
||||||
|
|
||||||
|
// Additional missing system strings
|
||||||
|
"端口": "Port",
|
||||||
|
"错误详情": "Error Details",
|
||||||
|
"正在搜索网络": "Searching network",
|
||||||
|
"SM-DP+ 的公开 Profile 库存已耗尽,请稍后重试或更换服务。":
|
||||||
|
"The public profile inventory on the SM-DP+ is exhausted. Please try again later or use a different service.",
|
||||||
|
"此 SM-DP+ 的证书链不受当前 eUICC 信任;该卡不能使用此测试服务器。":
|
||||||
|
"The SM-DP+ certificate chain is not trusted by this eUICC; this card cannot use this test server.",
|
||||||
|
"激活码已被使用、已过期或被 SM-DP+ 拒绝,请更换新的 Matching ID。":
|
||||||
|
"The activation code has already been used, expired, or was rejected by SM-DP+. Please use a new Matching ID.",
|
||||||
|
"已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 ":
|
||||||
|
"Modem detected, but no AT serial port found: option driver may not recognize this PID or modem is in MBIM/RNDIS mode. You can ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+121
-139
@@ -314,7 +314,7 @@ export default function SmsPage() {
|
|||||||
const selectContact = useCallback(
|
const selectContact = useCallback(
|
||||||
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
|
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
|
||||||
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
|
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
|
||||||
if (!key || (keyRef.current === key && messagesRef.current.length > 0)) return;
|
if (!key) return;
|
||||||
setKey(key);
|
setKey(key);
|
||||||
if (syncRoute) syncQuery(deviceRef.current, key);
|
if (syncRoute) syncQuery(deviceRef.current, key);
|
||||||
const thread = contactsRef.current.find((t) => t.key === key) || null;
|
const thread = contactsRef.current.find((t) => t.key === key) || null;
|
||||||
@@ -338,8 +338,8 @@ export default function SmsPage() {
|
|||||||
contactsList: SmsThread[],
|
contactsList: SmsThread[],
|
||||||
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
|
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const { syncRoute = false, silent = false, scrollToBottom = false } = opts;
|
const { silent = false, scrollToBottom = false } = opts;
|
||||||
const active = contactsList.find((t) => t.key === keyRef.current) || null;
|
const active = (keyRef.current && contactsList.find((t) => t.key === keyRef.current)) || null;
|
||||||
if (active) {
|
if (active) {
|
||||||
const ok = await loadThreadFor(active, device, silent);
|
const ok = await loadThreadFor(active, device, silent);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -350,16 +350,8 @@ export default function SmsPage() {
|
|||||||
}
|
}
|
||||||
setMessagesState([]);
|
setMessagesState([]);
|
||||||
setHasMoreState(false);
|
setHasMoreState(false);
|
||||||
if (keyRef.current) {
|
|
||||||
setKey("");
|
|
||||||
if (syncRoute) syncQuery(device, "");
|
|
||||||
}
|
|
||||||
const filtered = filterThreads(contactsList, searchRef.current);
|
|
||||||
if (!isMobileRef.current && filtered.length > 0) {
|
|
||||||
await selectContact(filtered[0].key, { syncRoute, silent, scrollToBottom });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[loadThreadFor, selectContact, syncQuery, scrollToBottomNow],
|
[loadThreadFor, scrollToBottomNow],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearSelection = useCallback(
|
const clearSelection = useCallback(
|
||||||
@@ -479,7 +471,7 @@ export default function SmsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [composer, devices, refreshCurrent, scrollToBottomNow]);
|
}, [composer, devices, refreshCurrent, scrollToBottomNow, t]);
|
||||||
|
|
||||||
const openNewSms = useCallback(() => {
|
const openNewSms = useCallback(() => {
|
||||||
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
|
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
|
||||||
@@ -506,7 +498,7 @@ export default function SmsPage() {
|
|||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshCurrent],
|
[refreshCurrent, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteMessageAction = useCallback(
|
const deleteMessageAction = useCallback(
|
||||||
@@ -530,7 +522,7 @@ export default function SmsPage() {
|
|||||||
setDeletingMessageId(null);
|
setDeletingMessageId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingMessageId, refreshCurrent, clearSelection],
|
[deletingMessageId, refreshCurrent, clearSelection, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteThreadAction = useCallback(
|
const deleteThreadAction = useCallback(
|
||||||
@@ -560,7 +552,7 @@ export default function SmsPage() {
|
|||||||
setDeletingThreadKey(null);
|
setDeletingThreadKey(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingThreadKey, clearSelection, loadContacts],
|
[deletingThreadKey, clearSelection, loadContacts, lang],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeActionSheet = useCallback(() => {
|
const closeActionSheet = useCallback(() => {
|
||||||
@@ -622,16 +614,6 @@ export default function SmsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const prevIsMobile = useRef(isMobile);
|
|
||||||
useEffect(() => {
|
|
||||||
const was = prevIsMobile.current;
|
|
||||||
prevIsMobile.current = isMobile;
|
|
||||||
if (was && !isMobile && !keyRef.current) {
|
|
||||||
const filtered = filterThreads(contactsRef.current, searchRef.current);
|
|
||||||
if (filtered.length > 0) void selectContact(filtered[0].key, { syncRoute: true, scrollToBottom: false });
|
|
||||||
}
|
|
||||||
}, [isMobile, selectContact]);
|
|
||||||
|
|
||||||
useEffect(() => () => clearLongPress(), [clearLongPress]);
|
useEffect(() => () => clearLongPress(), [clearLongPress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -670,121 +652,121 @@ export default function SmsPage() {
|
|||||||
onRetry={refreshAll}
|
onRetry={refreshAll}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative flex-1 overflow-hidden ui-card">
|
<div className="relative flex-1 overflow-hidden ui-card">
|
||||||
{contactsLoading && contacts.length === 0 ? (
|
{contactsLoading && contacts.length === 0 ? (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
|
||||||
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
|
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="sms-main-layout">
|
|
||||||
{isDesktop ? (
|
|
||||||
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
|
|
||||||
<div className="border-b border-gray-100 p-4 dark:border-white/10">
|
|
||||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 overflow-auto p-3">
|
|
||||||
{deviceFilters.map((d) => (
|
|
||||||
<button
|
|
||||||
key={d.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void selectDevice(d.id)}
|
|
||||||
className={cx(
|
|
||||||
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
|
|
||||||
selectedDevice === d.id
|
|
||||||
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
|
|
||||||
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
|
|
||||||
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{d.id !== "all" ? (
|
) : null}
|
||||||
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
|
<div className="sms-main-layout">
|
||||||
|
{isDesktop ? (
|
||||||
|
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
|
||||||
|
<div className="border-b border-gray-100 p-4 dark:border-white/10">
|
||||||
|
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 overflow-auto p-3">
|
||||||
|
{deviceFilters.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void selectDevice(d.id)}
|
||||||
|
className={cx(
|
||||||
|
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
|
||||||
|
selectedDevice === d.id
|
||||||
|
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
|
||||||
|
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
|
||||||
|
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
|
||||||
|
</div>
|
||||||
|
{d.id !== "all" ? (
|
||||||
|
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
{showContactColumn ? (
|
||||||
))}
|
<ContactList
|
||||||
</div>
|
isMobile={isMobile}
|
||||||
</div>
|
isDesktop={isDesktop}
|
||||||
) : null}
|
selectedDevice={selectedDevice}
|
||||||
{showContactColumn ? (
|
deviceOptions={deviceSelectOptions}
|
||||||
<ContactList
|
onSelectDevice={(id) => void selectDevice(id)}
|
||||||
isMobile={isMobile}
|
searchQuery={searchQuery}
|
||||||
isDesktop={isDesktop}
|
onSearchChange={setSearch}
|
||||||
selectedDevice={selectedDevice}
|
loading={contactsLoading}
|
||||||
deviceOptions={deviceSelectOptions}
|
contacts={filteredContacts}
|
||||||
onSelectDevice={(id) => void selectDevice(id)}
|
activeKey={selectedKey}
|
||||||
searchQuery={searchQuery}
|
isUnread={isUnread}
|
||||||
onSearchChange={setSearch}
|
deletingKey={deletingThreadKey}
|
||||||
loading={contactsLoading}
|
canHover={canHover}
|
||||||
contacts={filteredContacts}
|
onSelect={(key) => void selectContact(key)}
|
||||||
activeKey={selectedKey}
|
onDelete={(t) => void deleteThreadAction(t)}
|
||||||
isUnread={isUnread}
|
onRowPointerDown={onThreadPointerDown}
|
||||||
deletingKey={deletingThreadKey}
|
onRowPointerMove={moveLongPress}
|
||||||
canHover={canHover}
|
onRowPointerEnd={clearLongPress}
|
||||||
onSelect={(key) => void selectContact(key)}
|
/>
|
||||||
onDelete={(t) => void deleteThreadAction(t)}
|
) : null}
|
||||||
onRowPointerDown={onThreadPointerDown}
|
{showDetailColumn ? (
|
||||||
onRowPointerMove={moveLongPress}
|
<ThreadPanel
|
||||||
onRowPointerEnd={clearLongPress}
|
isMobile={isMobile}
|
||||||
/>
|
isDesktop={isDesktop}
|
||||||
) : null}
|
selectedDevice={selectedDevice}
|
||||||
{showDetailColumn ? (
|
activeThread={activeThread}
|
||||||
<ThreadPanel
|
canLoadMore={!!activeThread && hasMore}
|
||||||
isMobile={isMobile}
|
loadingMore={loadingMore}
|
||||||
isDesktop={isDesktop}
|
groups={groups}
|
||||||
selectedDevice={selectedDevice}
|
deletingMessageId={deletingMessageId}
|
||||||
activeThread={activeThread}
|
canHover={canHover}
|
||||||
canLoadMore={!!activeThread && hasMore}
|
composer={composer}
|
||||||
loadingMore={loadingMore}
|
composerInfo={composerInfo}
|
||||||
groups={groups}
|
composerLength={composerLength}
|
||||||
deletingMessageId={deletingMessageId}
|
sending={sending}
|
||||||
canHover={canHover}
|
detailRef={detailRef}
|
||||||
composer={composer}
|
composerRef={composerRef}
|
||||||
composerInfo={composerInfo}
|
onBack={onBack}
|
||||||
composerLength={composerLength}
|
onScrollToBottom={scrollToBottomNow}
|
||||||
sending={sending}
|
onLoadMore={() => void loadMore()}
|
||||||
detailRef={detailRef}
|
onDeleteMessage={(m) => void deleteMessageAction(m)}
|
||||||
composerRef={composerRef}
|
onComposerChange={setComposer}
|
||||||
onBack={onBack}
|
onSend={() => void sendReply()}
|
||||||
onScrollToBottom={scrollToBottomNow}
|
onDetailScroll={onDetailScroll}
|
||||||
onLoadMore={() => void loadMore()}
|
onMsgPointerDown={onMsgPointerDown}
|
||||||
onDeleteMessage={(m) => void deleteMessageAction(m)}
|
onMsgPointerMove={moveLongPress}
|
||||||
onComposerChange={setComposer}
|
onMsgPointerEnd={clearLongPress}
|
||||||
onSend={() => void sendReply()}
|
/>
|
||||||
onDetailScroll={onDetailScroll}
|
) : null}
|
||||||
onMsgPointerDown={onMsgPointerDown}
|
</div>
|
||||||
onMsgPointerMove={moveLongPress}
|
</div>
|
||||||
onMsgPointerEnd={clearLongPress}
|
{actionSheetOpen && isMobile && actionTarget ? (
|
||||||
/>
|
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
|
||||||
) : null}
|
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
</div>
|
<div className="sms-action-sheet-title">{t("操作")}</div>
|
||||||
</div>
|
<Button
|
||||||
{actionSheetOpen && isMobile && actionTarget ? (
|
className="sms-danger-ghost-btn !w-full !justify-center"
|
||||||
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
|
icon={<DeleteRegular />}
|
||||||
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
|
onClick={() => void confirmSheetAction()}
|
||||||
<div className="sms-action-sheet-title">{t("操作")}</div>
|
>
|
||||||
<Button
|
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
|
||||||
className="sms-danger-ghost-btn !w-full !justify-center"
|
</Button>
|
||||||
icon={<DeleteRegular />}
|
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
|
||||||
onClick={() => void confirmSheetAction()}
|
{t("取消")}
|
||||||
>
|
</Button>
|
||||||
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
|
) : null}
|
||||||
{t("取消")}
|
<NewSmsModal
|
||||||
</Button>
|
open={newSmsOpen}
|
||||||
</div>
|
devices={devices}
|
||||||
</div>
|
defaultDeviceId={newSmsDevice}
|
||||||
) : null}
|
sending={sending}
|
||||||
<NewSmsModal
|
onClose={() => setNewSmsOpen(false)}
|
||||||
open={newSmsOpen}
|
onSend={sendNewSms}
|
||||||
devices={devices}
|
/>
|
||||||
defaultDeviceId={newSmsDevice}
|
|
||||||
sending={sending}
|
|
||||||
onClose={() => setNewSmsOpen(false)}
|
|
||||||
onSend={sendNewSms}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const {
|
|||||||
} = await import(moduleURL);
|
} = await import(moduleURL);
|
||||||
|
|
||||||
test("uses the current physical SIM when the device has no eSIM profiles", () => {
|
test("uses the current physical SIM when the device has no eSIM profiles", () => {
|
||||||
const iccid = "89441000400128014257";
|
const iccid = "8944100000000000001";
|
||||||
|
|
||||||
assert.deepEqual(buildAutomaticTaskProfileOptions([], iccid, "Current SIM"), [
|
assert.deepEqual(buildAutomaticTaskProfileOptions([], iccid, "Current SIM"), [
|
||||||
{
|
{
|
||||||
@@ -30,7 +30,7 @@ test("uses the current physical SIM when the device has no eSIM profiles", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
|
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
|
||||||
const iccid = "89441000400128014257";
|
const iccid = "8944100000000000001";
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildAutomaticTaskProfileOptions(
|
buildAutomaticTaskProfileOptions(
|
||||||
@@ -49,7 +49,7 @@ test("does not duplicate the current SIM when it is already in the eSIM inventor
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("does not replace a saved profile when a failed inventory only exposes the current SIM", () => {
|
test("does not replace a saved profile when a failed inventory only exposes the current SIM", () => {
|
||||||
const currentICCID = "89441000400128014257";
|
const currentICCID = "8944100000000000001";
|
||||||
const savedICCID = "89104100000028106378";
|
const savedICCID = "89104100000028106378";
|
||||||
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user