mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-16 21:03:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5774b196d2 | ||
|
|
5604813254 | ||
|
|
38b4264133 | ||
|
|
df9dbfee74 | ||
|
|
8dc6d4f620 | ||
|
|
70bdb88e4c | ||
|
|
091d12cd30 | ||
|
|
c4c6a6c181 | ||
|
|
540a967f96 | ||
|
|
fcf57ffec9 | ||
|
|
4b1bcdf582 | ||
|
|
0d1779bf25 | ||
|
|
08cdd99141 | ||
|
|
f6cd31f50b | ||
|
|
e442dde284 | ||
|
|
083a952480 | ||
|
|
67f2ddbaeb | ||
|
|
e546e810fe | ||
|
|
30c0d2d9d5 | ||
|
|
64cd5714ef | ||
|
|
aa54ad8f6a | ||
|
|
c7c7174b45 | ||
|
|
b45f7825ca | ||
|
|
c436e12532 | ||
|
|
0c380c1e07 | ||
|
|
c63c765d14 | ||
|
|
18408106fa | ||
|
|
d0fd59a2a4 | ||
|
|
79ab0573e0 | ||
|
|
053d9d275c | ||
|
|
07a20201e7 | ||
|
|
6dbceaa25c | ||
|
|
ccf4de3f9a | ||
|
|
d63325f06d | ||
|
|
c73d95b2b6 | ||
|
|
25e1c8fdd9 | ||
|
|
8ff337001e | ||
|
|
c7e05b201b | ||
|
|
09cdd8c102 | ||
|
|
154eb24700 | ||
|
|
9a0f8f4b79 | ||
|
|
e4434bbfbf | ||
|
|
f530e7722d | ||
|
|
4f7873f3b1 | ||
|
|
8bb20aa9f0 | ||
|
|
342d88e0cf | ||
|
|
328605336b | ||
|
|
30afe090d3 | ||
|
|
ede7a8aa19 | ||
|
|
177dde48b0 | ||
|
|
07e47d012d | ||
|
|
111b3de6f2 | ||
|
|
ecf36a8f2e | ||
|
|
be174d09ae | ||
|
|
e9d8dbf996 | ||
|
|
704e5d0656 | ||
|
|
f4aaa3cfdd | ||
|
|
70191ce1e3 | ||
|
|
7dfdf8fcc8 | ||
|
|
f1b7fc3c56 | ||
|
|
6d451ca9a3 | ||
|
|
42a21c6dc2 | ||
|
|
6882b04fda | ||
|
|
a8e8caf78d | ||
|
|
670b029b38 | ||
|
|
a5c6fee2ef | ||
|
|
79dc2bc934 | ||
|
|
7f162d43ef | ||
|
|
1016162600 | ||
|
|
f3e4b675fb | ||
|
|
8fdce234ce | ||
|
|
0ed066fcc7 | ||
|
|
cbeb6cdd21 | ||
|
|
cd04642e50 | ||
|
|
ddec50a7b6 |
@@ -0,0 +1,75 @@
|
||||
name: Issue Report
|
||||
description: Report a bug or problem with VoCat. Please answer every question below.
|
||||
title: "[Issue]: "
|
||||
labels: ["triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to open an issue. Please fill in all the fields
|
||||
below so we can triage and handle your report as quickly as possible.
|
||||
|
||||
- type: checkboxes
|
||||
id: searched-existing
|
||||
attributes:
|
||||
label: Existing Issues
|
||||
description: Have you searched through past Issues (both open and closed) to check whether this problem, or a similar one, has already been reported?
|
||||
options:
|
||||
- label: I have searched past Issues and found no similar report.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the problem you encountered and what you expected to happen instead.
|
||||
placeholder: A clear and concise description of the issue...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Suggested Priority
|
||||
description: In your opinion, what priority should this issue be handled with?
|
||||
options:
|
||||
- Low
|
||||
- Medium
|
||||
- High
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected Area
|
||||
description: Do you think this is a frontend or backend error?
|
||||
options:
|
||||
- Frontend
|
||||
- Backend
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: abuse-mcc-acknowledgement
|
||||
attributes:
|
||||
label: Acknowledgement
|
||||
description: Please read and confirm the following before submitting.
|
||||
options:
|
||||
- label: >-
|
||||
I understand that this repository will not modify any feature code on
|
||||
behalf of abusers in order to enable abuse, and that this service must
|
||||
not be used in regions with MCC=460; any issues
|
||||
arising from such use will not be resolved.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error-messages
|
||||
attributes:
|
||||
label: Error Messages
|
||||
description: Did you encounter any error messages? If so, please paste them here.
|
||||
placeholder: Paste any relevant error output or logs...
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,282 @@
|
||||
name: Pull request size limit
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- master
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: pr-size-limit-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
enforce-size-limit:
|
||||
# 保持这个名字不变,这样你 Ruleset 里的 Required Check 不需要修改
|
||||
name: Enforce 5,000-line limit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
MAX_CHANGED_LINES: "5000"
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted base repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check conflicts and pull request size
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Checking PR #${PR_NUMBER}"
|
||||
echo "Base branch: ${BASE_REF}"
|
||||
|
||||
############################################################
|
||||
# Helper: comment on and close rejected PR
|
||||
############################################################
|
||||
|
||||
reject_pr() {
|
||||
local message="$1"
|
||||
|
||||
echo "::error::${message}"
|
||||
|
||||
COMMENT_PAYLOAD="$(
|
||||
jq -nc \
|
||||
--arg body "${message}" \
|
||||
'{body: $body}'
|
||||
)"
|
||||
|
||||
echo "Posting rejection comment..."
|
||||
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--request POST \
|
||||
--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}/issues/${PR_NUMBER}/comments" \
|
||||
--data "${COMMENT_PAYLOAD}" \
|
||||
>/dev/null
|
||||
|
||||
echo "Closing PR #${PR_NUMBER}..."
|
||||
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--request PATCH \
|
||||
--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}" \
|
||||
--data '{"state":"closed"}' \
|
||||
>/dev/null
|
||||
|
||||
exit 1
|
||||
}
|
||||
|
||||
############################################################
|
||||
# Fetch target branch and PR HEAD
|
||||
############################################################
|
||||
|
||||
echo "Fetching base branch and PR head..."
|
||||
|
||||
git fetch --no-tags --force origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/base-pr-check" \
|
||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}"
|
||||
|
||||
BASE_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/base-pr-check
|
||||
)"
|
||||
|
||||
PR_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/pr-${PR_NUMBER}
|
||||
)"
|
||||
|
||||
echo "Base commit: ${BASE_COMMIT}"
|
||||
echo "PR commit: ${PR_COMMIT}"
|
||||
|
||||
############################################################
|
||||
# STEP 1: Reject PRs with merge conflicts
|
||||
############################################################
|
||||
|
||||
echo
|
||||
echo "Checking for merge conflicts..."
|
||||
|
||||
set +e
|
||||
|
||||
git merge-tree \
|
||||
--write-tree \
|
||||
--quiet \
|
||||
"${BASE_COMMIT}" \
|
||||
"${PR_COMMIT}"
|
||||
|
||||
MERGE_STATUS=$?
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "${MERGE_STATUS}" -eq 1 ]]; then
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflicts: ❌ Detected"
|
||||
echo "- Result: Rejected"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reject_pr "This pull request has merge conflicts with the current master branch and cannot be accepted. Please update your branch with the latest master, resolve all merge conflicts locally, and submit a conflict-free pull request."
|
||||
|
||||
elif [[ "${MERGE_STATUS}" -ne 0 ]]; then
|
||||
|
||||
echo "::error::Unable to determine whether the pull request can be merged."
|
||||
echo "git merge-tree returned status ${MERGE_STATUS}."
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflict check: ⚠️ Error"
|
||||
echo "- Result: Check failed"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
echo "No merge conflicts detected."
|
||||
|
||||
############################################################
|
||||
# STEP 2: Determine merge base
|
||||
############################################################
|
||||
|
||||
if ! MERGE_BASE="$(
|
||||
git merge-base "${BASE_COMMIT}" "${PR_COMMIT}"
|
||||
)"; then
|
||||
|
||||
echo "::error::Unable to determine merge base."
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflicts: ✅ None"
|
||||
echo "- Diff calculation: ⚠️ Failed"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
echo "Merge base: ${MERGE_BASE}"
|
||||
|
||||
############################################################
|
||||
# STEP 3: Calculate actual PR changed lines
|
||||
############################################################
|
||||
|
||||
NUMSTAT_FILE="$(mktemp)"
|
||||
|
||||
git diff \
|
||||
--no-ext-diff \
|
||||
--no-textconv \
|
||||
--numstat \
|
||||
"${MERGE_BASE}" \
|
||||
"${PR_COMMIT}" \
|
||||
> "${NUMSTAT_FILE}"
|
||||
|
||||
ADDITIONS="$(
|
||||
awk '
|
||||
$1 ~ /^[0-9]+$/ {
|
||||
total += $1
|
||||
}
|
||||
|
||||
END {
|
||||
print total + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
DELETIONS="$(
|
||||
awk '
|
||||
$2 ~ /^[0-9]+$/ {
|
||||
total += $2
|
||||
}
|
||||
|
||||
END {
|
||||
print total + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
CHANGED_FILES="$(
|
||||
awk '
|
||||
END {
|
||||
print NR + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
CHANGED_LINES=$((ADDITIONS + DELETIONS))
|
||||
|
||||
############################################################
|
||||
# Action summary
|
||||
############################################################
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflicts: ✅ None"
|
||||
echo "- Changed files: ${CHANGED_FILES}"
|
||||
echo "- Additions: ${ADDITIONS}"
|
||||
echo "- Deletions: ${DELETIONS}"
|
||||
echo "- Total changed lines: ${CHANGED_LINES}"
|
||||
echo "- Maximum allowed: ${MAX_CHANGED_LINES}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
echo
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
echo "Additions: ${ADDITIONS}"
|
||||
echo "Deletions: ${DELETIONS}"
|
||||
echo "Total changed lines: ${CHANGED_LINES}"
|
||||
echo "Limit: ${MAX_CHANGED_LINES}"
|
||||
|
||||
############################################################
|
||||
# STEP 4: Reject oversized PRs
|
||||
############################################################
|
||||
|
||||
if (( CHANGED_LINES > MAX_CHANGED_LINES )); then
|
||||
|
||||
reject_pr "This pull request changes ${CHANGED_LINES} lines (${ADDITIONS} additions + ${DELETIONS} deletions) across ${CHANGED_FILES} files, exceeding the repository limit of ${MAX_CHANGED_LINES} changed lines. It has been closed automatically. Please split the changes into smaller pull requests."
|
||||
|
||||
fi
|
||||
|
||||
############################################################
|
||||
# PASS
|
||||
############################################################
|
||||
|
||||
echo
|
||||
echo "Pull request passed all policy checks."
|
||||
echo "No merge conflicts."
|
||||
echo "Changed lines: ${CHANGED_LINES}/${MAX_CHANGED_LINES}."
|
||||
|
||||
{
|
||||
echo
|
||||
echo "### Result"
|
||||
echo
|
||||
echo "✅ Pull request passed."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
@@ -10,6 +10,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/fix
|
||||
|
||||
# ---- Cookie / secret files (NEVER commit) ----
|
||||
vc.jar
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
||||
|
||||
# ---- Stage 3: minimal runtime ----
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates ccid pcsc-lite tzdata && \
|
||||
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \
|
||||
addgroup -S -g 1000 vocat && \
|
||||
adduser -S -D -H -u 1000 -G vocat vocat
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 iniwex5
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
VoCat uses the following Go module for native Qualcomm QMI support:
|
||||
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
||||
Distribution: https://proxy.golang.org/github.com/iniwex5/quectel-qmi-go/@v/v0.6.0.zip
|
||||
Documentation and license metadata: https://pkg.go.dev/github.com/iniwex5/[email protected]
|
||||
License: MIT
|
||||
Copyright: Copyright (c) 2026 iniwex5
|
||||
|
||||
The full MIT license text is included in:
|
||||
LICENSES/quectel-qmi-go-MIT.txt
|
||||
@@ -23,14 +23,14 @@ func runBootstrapAdmin(args []string) error {
|
||||
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
|
||||
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
|
||||
}
|
||||
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
|
||||
if len(password) < 12 || len(password) > 1024 {
|
||||
return errors.New("bootstrap password must contain between 12 and 1024 characters")
|
||||
if password == "" {
|
||||
return errors.New("bootstrap password cannot be empty")
|
||||
}
|
||||
adminUsername := strings.TrimSpace(*username)
|
||||
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
|
||||
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(scriptBytes)
|
||||
mainStart := strings.LastIndex(script, "# --- Main ")
|
||||
if mainStart < 0 {
|
||||
t.Fatal("installer main section not found")
|
||||
}
|
||||
main := script[mainStart:]
|
||||
validateAt := strings.Index(main, `bootstrap_admin "${VOCAT_TMP}/vocat"`)
|
||||
installAt := strings.Index(main, "install_binary")
|
||||
if validateAt < 0 {
|
||||
t.Fatal("installer does not validate the database with the downloaded binary")
|
||||
}
|
||||
if installAt < 0 {
|
||||
t.Fatal("installer does not install the downloaded binary")
|
||||
}
|
||||
if validateAt > installAt {
|
||||
t.Fatal("installer replaces the current binary before validating database compatibility")
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -27,6 +27,7 @@ import (
|
||||
"vocat/internal/extensions"
|
||||
"vocat/internal/httpsmode"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
"vocat/internal/server"
|
||||
"vocat/internal/store"
|
||||
@@ -836,9 +837,9 @@ func provisionDiscoveredDevices(
|
||||
}
|
||||
for _, discovered := range manager.List() {
|
||||
candidate := discovered.Candidate
|
||||
deviceType := provisionedDeviceType(candidate)
|
||||
backend := "at"
|
||||
control := candidate.ATPort.OpenPath()
|
||||
deviceType := store.DeviceTypePCIeEC20EC25
|
||||
esimTransport := backend
|
||||
if candidate.QMIControl != "" {
|
||||
backend = "qmi"
|
||||
@@ -880,6 +881,15 @@ func provisionDiscoveredDevices(
|
||||
return nil
|
||||
}
|
||||
|
||||
func provisionedDeviceType(candidate modem.Candidate) string {
|
||||
controlName := filepath.Base(filepath.Clean(candidate.QMIControl))
|
||||
if candidate.HardwareKind == "wwan" &&
|
||||
strings.HasPrefix(controlName, "wwan") && strings.Contains(controlName, "qmi") {
|
||||
return store.DeviceTypeWiFi410
|
||||
}
|
||||
return store.DeviceTypePCIeEC20EC25
|
||||
}
|
||||
|
||||
// persistLogsToStore subscribes to the live log hub and durably appends every
|
||||
// entry to the log_events table, so runtime logs survive restarts and can be
|
||||
// pruned by the configured retention policy.
|
||||
|
||||
@@ -217,3 +217,24 @@ func TestEnforceCardRegionIgnoresUnknownOrNotReadySIM(t *testing.T) {
|
||||
t.Fatalf("expected no card policies, got %d", len(policies))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionedDeviceTypeRecognizesNativeWWAN(t *testing.T) {
|
||||
native := modem.Candidate{
|
||||
HardwareKind: "wwan",
|
||||
USBPath: "/sys/devices/pci0000:00/0000:00:00.0/wwan/wwan0",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
ATPort: modem.Port{Path: "/dev/wwan0at0"},
|
||||
}
|
||||
if got := provisionedDeviceType(native); got != store.DeviceTypeWiFi410 {
|
||||
t.Fatalf("native WWAN type = %q, want %q", got, store.DeviceTypeWiFi410)
|
||||
}
|
||||
|
||||
usb := modem.Candidate{
|
||||
USBPath: "/sys/bus/usb/devices/1-6",
|
||||
QMIControl: "/dev/cdc-wdm0",
|
||||
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
|
||||
}
|
||||
if got := provisionedDeviceType(usb); got != store.DeviceTypePCIeEC20EC25 {
|
||||
t.Fatalf("USB modem type = %q, want %q", got, store.DeviceTypePCIeEC20EC25)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-29
@@ -88,7 +88,7 @@ func menuEnvFilePath() string {
|
||||
return envFilePath
|
||||
}
|
||||
|
||||
// runMenu is the interactive lifecycle menu: toggle language, change password,
|
||||
// runMenu is the interactive lifecycle menu: toggle language, reset credentials,
|
||||
// change the Web listener port, restart the systemd unit, self-update, or fully
|
||||
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
|
||||
// env file). Docker deployments do not use it.
|
||||
@@ -128,7 +128,7 @@ func runMenu(logger *slog.Logger) error {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "2":
|
||||
if err := menuChangePassword(reader, menu); err != nil {
|
||||
if err := menuResetAdminCredentials(reader, menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "3":
|
||||
@@ -193,13 +193,12 @@ func loadMenuLanguage() (string, error) {
|
||||
return "en", nil
|
||||
}
|
||||
|
||||
func menuChangePassword(reader *bufio.Reader, m *menu) error {
|
||||
func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
ctx := context.Background()
|
||||
|
||||
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
@@ -215,11 +214,14 @@ func menuChangePassword(reader *bufio.Reader, m *menu) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
|
||||
fmt.Print(m.currentPassword())
|
||||
currentPw, err := readPasswordMasked()
|
||||
fmt.Print(m.newUsername(admin.Username))
|
||||
username, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("read administrator username: %w", err)
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
username = admin.Username
|
||||
}
|
||||
fmt.Print(m.newPassword())
|
||||
newPw, err := readPasswordMasked()
|
||||
@@ -235,10 +237,7 @@ func menuChangePassword(reader *bufio.Reader, m *menu) error {
|
||||
if newPw != confirmPw {
|
||||
return errPasswordsDiffer
|
||||
}
|
||||
if err := authService.ChangePassword(ctx, admin.Username, currentPw, newPw); err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
return errCurrentWrong
|
||||
}
|
||||
if err := authService.ResetAdminCredentials(ctx, username, newPw); err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuAuth, err)
|
||||
}
|
||||
fmt.Println(m.passwordChanged())
|
||||
@@ -524,6 +523,7 @@ func menuUpdate(m *menu, logger *slog.Logger) error {
|
||||
}
|
||||
fmt.Println(m.updateChecking())
|
||||
if err := update.Run(logger, []string{"--repo", repo}); err != nil {
|
||||
logger.Error("menu update failed", "error", err)
|
||||
return fmt.Errorf("%w: %v", errUpdateFailed, err)
|
||||
}
|
||||
return nil
|
||||
@@ -563,7 +563,6 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
|
||||
|
||||
// menu-local sentinel errors so callers can map them to localized messages.
|
||||
var (
|
||||
errCurrentWrong = errors.New("menu: current password is incorrect")
|
||||
errPasswordsDiffer = errors.New("menu: passwords do not match")
|
||||
errNoSystemctl = errors.New("menu: systemctl not found")
|
||||
errRestartFailed = errors.New("menu: restart failed")
|
||||
@@ -588,17 +587,17 @@ func (m *menu) msg(key string) string {
|
||||
table := map[string][2]string{
|
||||
"title": {"vocat 管理菜单", "vocat management menu"},
|
||||
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
|
||||
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
|
||||
"opt_change": {"2) 修改账号密码", "2) Change admin credentials"},
|
||||
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
|
||||
"opt_restart": {"4) 重启软件", "4) Restart software"},
|
||||
"opt_update": {"5) 更新软件", "5) Update software"},
|
||||
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"cur_pw": {"当前密码: ", "Current password: "},
|
||||
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
||||
"new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "},
|
||||
"new_pw": {"新密码: ", "New password: "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
|
||||
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
|
||||
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
||||
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
|
||||
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
|
||||
@@ -632,10 +631,12 @@ func (m *menu) msg(key string) string {
|
||||
return entry[zh]
|
||||
}
|
||||
|
||||
func (m *menu) title() string { return m.msg("title") }
|
||||
func (m *menu) prompt() string { return m.msg("prompt") }
|
||||
func (m *menu) invalid() string { return m.msg("invalid") }
|
||||
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
|
||||
func (m *menu) title() string { return m.msg("title") }
|
||||
func (m *menu) prompt() string { return m.msg("prompt") }
|
||||
func (m *menu) invalid() string { return m.msg("invalid") }
|
||||
func (m *menu) newUsername(current string) string {
|
||||
return fmt.Sprintf(m.msg("new_username"), current)
|
||||
}
|
||||
func (m *menu) newPassword() string { return m.msg("new_pw") }
|
||||
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
|
||||
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
|
||||
@@ -670,11 +671,6 @@ func (m *menu) options() []string {
|
||||
|
||||
func (m *menu) errorPrefix(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, errCurrentWrong):
|
||||
if m.lang == "en" {
|
||||
return "Current password is incorrect."
|
||||
}
|
||||
return "当前密码不正确。"
|
||||
case errors.Is(err, errPasswordsDiffer):
|
||||
if m.lang == "en" {
|
||||
return "Passwords do not match."
|
||||
@@ -691,10 +687,11 @@ func (m *menu) errorPrefix(err error) string {
|
||||
}
|
||||
return "重启失败。"
|
||||
case errors.Is(err, errUpdateFailed):
|
||||
detail := strings.TrimPrefix(err.Error(), errUpdateFailed.Error()+": ")
|
||||
if m.lang == "en" {
|
||||
return "Update failed."
|
||||
return "Update failed: " + detail
|
||||
}
|
||||
return "更新失败。"
|
||||
return "更新失败: " + detail
|
||||
case errors.Is(err, errMenuConfig):
|
||||
if m.lang == "en" {
|
||||
return "Failed to load configuration."
|
||||
|
||||
@@ -72,3 +72,20 @@ func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuCredentialResetPromptsDoNotRequestCurrentPassword(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
menu := newMenu(lang)
|
||||
prompts := strings.Join([]string{
|
||||
menu.newUsername("admin"),
|
||||
menu.newPassword(),
|
||||
menu.confirmPassword(),
|
||||
}, "\n")
|
||||
if strings.Contains(strings.ToLower(prompts), "current password") || strings.Contains(prompts, "当前密码") {
|
||||
t.Fatalf("%s credential reset still requests the current password: %q", lang, prompts)
|
||||
}
|
||||
if !strings.Contains(prompts, "admin") {
|
||||
t.Fatalf("%s username prompt does not show the current username: %q", lang, prompts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
@@ -18,6 +19,8 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/warthog618/sms v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -2,6 +2,7 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -10,16 +11,25 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/warthog618/sms v0.3.0 h1:LYAb5ngmu2qjNExgji3B7xi2tIZ9+DsuE9pC5xs4wwc=
|
||||
github.com/warthog618/sms v0.3.0/go.mod h1:+bYZGeBxu003sxD5xhzsrIPBAjPBzTABsRTwSpd7ld4=
|
||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
@@ -37,6 +47,10 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
|
||||
+64
-10
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -20,8 +21,13 @@ var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidCSRF = errors.New("invalid csrf token")
|
||||
ErrEmptyPassword = errors.New("password cannot be empty")
|
||||
)
|
||||
|
||||
const bcryptPasswordLimit = 72
|
||||
|
||||
var longPasswordHashPrefix = []byte("$vocat-sha256$")
|
||||
|
||||
type Options struct {
|
||||
SessionTTL time.Duration
|
||||
BcryptCost int
|
||||
@@ -85,14 +91,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
|
||||
current, err := s.store.CurrentAdmin(ctx)
|
||||
if err == nil &&
|
||||
current.Username == username &&
|
||||
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil {
|
||||
comparePassword(current.PasswordHash, password) == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("auth: read configured admin: %w", err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
|
||||
passwordHash, err := hashPassword(password, s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash admin password: %w", err)
|
||||
}
|
||||
@@ -118,16 +124,34 @@ func (s *Service) EnsureAdminIfMissing(ctx context.Context, username string, pas
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ResetAdminCredentials replaces the single administrator without requiring
|
||||
// the previous credentials. It is intended for trusted local recovery flows
|
||||
// such as the root-only management CLI. Store.SetAdmin atomically revokes all
|
||||
// existing sessions when the credentials change.
|
||||
func (s *Service) ResetAdminCredentials(ctx context.Context, username string, password string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") {
|
||||
return errors.New("administrator username must contain between 1 and 64 characters without control whitespace")
|
||||
}
|
||||
if password == "" {
|
||||
return ErrEmptyPassword
|
||||
}
|
||||
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
||||
return fmt.Errorf("auth: reset administrator credentials: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
|
||||
_ = comparePassword(s.dummyHash, password)
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil {
|
||||
if comparePassword(admin.PasswordHash, password) != nil {
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -266,24 +290,24 @@ func (s *Service) ChangePassword(
|
||||
currentPassword string,
|
||||
newPassword string,
|
||||
) error {
|
||||
if len(newPassword) < 12 || len(newPassword) > 1024 {
|
||||
return errors.New("new password must contain between 12 and 1024 characters")
|
||||
if newPassword == "" {
|
||||
return ErrEmptyPassword
|
||||
}
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword))
|
||||
_ = comparePassword(s.dummyHash, currentPassword)
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil {
|
||||
if comparePassword(admin.PasswordHash, currentPassword) != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil {
|
||||
if comparePassword(admin.PasswordHash, newPassword) == nil {
|
||||
return errors.New("new password must differ from the current password")
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost)
|
||||
passwordHash, err := hashPassword(newPassword, s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash new password: %w", err)
|
||||
}
|
||||
@@ -293,6 +317,36 @@ func (s *Service) ChangePassword(
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword keeps ordinary bcrypt hashes compatible with existing
|
||||
// installations. bcrypt rejects inputs longer than 72 bytes, so only longer
|
||||
// passwords use a tagged SHA-256 pre-hash before bcrypt.
|
||||
func hashPassword(password string, cost int) ([]byte, error) {
|
||||
material := []byte(password)
|
||||
longPassword := len(material) > bcryptPasswordLimit
|
||||
if longPassword {
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword(material, cost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !longPassword {
|
||||
return passwordHash, nil
|
||||
}
|
||||
return append(append([]byte(nil), longPasswordHashPrefix...), passwordHash...), nil
|
||||
}
|
||||
|
||||
func comparePassword(passwordHash []byte, password string) error {
|
||||
material := []byte(password)
|
||||
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
passwordHash = passwordHash[len(longPasswordHashPrefix):]
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword(passwordHash, material)
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -97,6 +98,73 @@ func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAdminCredentialsChangesUsernameAndPasswordWithoutOldPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newTestService(t)
|
||||
credentials, err := service.Login(ctx, "admin", "correct-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := service.ResetAdminCredentials(ctx, "new-admin", "replacement-password"); err != nil {
|
||||
t.Fatalf("ResetAdminCredentials() error = %v", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", "correct-password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("old credentials error = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "new-admin", "replacement-password"); err != nil {
|
||||
t.Fatalf("new credentials login error = %v", err)
|
||||
}
|
||||
if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("old session error = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAdminCredentialsValidatesInput(t *testing.T) {
|
||||
service := newTestService(t)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
}{
|
||||
{name: "empty username", password: "replacement-password"},
|
||||
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
|
||||
{name: "empty password", username: "admin", password: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
|
||||
t.Fatal("ResetAdminCredentials() accepted invalid input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, password := range []string{"1", strings.Repeat("x", 256)} {
|
||||
service := newTestService(t)
|
||||
if err := service.ResetAdminCredentials(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("ResetAdminCredentials(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, password := range []string{"1", strings.Repeat("long-password-", 32)} {
|
||||
service := newTestService(t)
|
||||
if err := service.ChangePassword(ctx, "admin", "correct-password", password); err != nil {
|
||||
t.Fatalf("ChangePassword(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newTestService(t)
|
||||
|
||||
@@ -14,7 +14,41 @@ import (
|
||||
var carrierDatabaseJSON []byte
|
||||
|
||||
type carrierDatabase struct {
|
||||
Carriers map[string][]string `json:"c"`
|
||||
Carriers map[string][]string `json:"c"`
|
||||
Countries map[string]string `json:"i"`
|
||||
Rules []carrierRule `json:"r"`
|
||||
}
|
||||
|
||||
type carrierRule struct {
|
||||
Name string `json:"n"`
|
||||
PLMNs []string `json:"m"`
|
||||
IMSIPatterns []string `json:"x"`
|
||||
SPNs []string `json:"s"`
|
||||
GID1Prefixes []string `json:"g1"`
|
||||
GID2Prefixes []string `json:"g2"`
|
||||
ICCIDPrefixes []string `json:"i"`
|
||||
}
|
||||
|
||||
// CarrierIdentity contains the SIM-issued values used by Android's carrier
|
||||
// resolver. MNC length comes from EF_AD; GID values come from EF_GID1/2.
|
||||
type CarrierIdentity struct {
|
||||
IMSI string
|
||||
ICCID string
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
MNCLength int
|
||||
}
|
||||
|
||||
// CountryForMCC returns the ISO alpha-2 country/territory code associated with
|
||||
// a three-digit mobile country code in the embedded Android carrier database.
|
||||
func CountryForMCC(mcc string) (string, bool) {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
if len(mcc) != 3 {
|
||||
return "", false
|
||||
}
|
||||
country := strings.ToUpper(strings.TrimSpace(globalCarrierDatabase.Countries[mcc]))
|
||||
return country, len(country) == 2
|
||||
}
|
||||
|
||||
var globalCarrierDatabase = func() carrierDatabase {
|
||||
@@ -64,3 +98,121 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
// CarrierForSIM applies the constrained Android carrier-ID rules before the
|
||||
// MCC/MNC fallback. This is important for MVNO and travel eSIM profiles where
|
||||
// several customer-facing carriers authenticate through the same home PLMN.
|
||||
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
if !decimalDigits(imsi, 5, 20) {
|
||||
return "", "", "", false
|
||||
}
|
||||
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
|
||||
bestScore := -1
|
||||
for _, rule := range globalCarrierDatabase.Rules {
|
||||
matchedPLMN := firstMatchingValue(rule.PLMNs, func(value string) bool {
|
||||
return containsString(plmns, value)
|
||||
})
|
||||
if matchedPLMN == "" {
|
||||
continue
|
||||
}
|
||||
score := 1 << 8
|
||||
if len(rule.IMSIPatterns) > 0 {
|
||||
if firstMatchingValue(rule.IMSIPatterns, func(pattern string) bool { return imsiPatternMatch(imsi, pattern) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 7
|
||||
}
|
||||
if len(rule.ICCIDPrefixes) > 0 {
|
||||
if firstMatchingValue(rule.ICCIDPrefixes, func(prefix string) bool { return strings.HasPrefix(identity.ICCID, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 6
|
||||
}
|
||||
if len(rule.GID1Prefixes) > 0 {
|
||||
if firstMatchingValue(rule.GID1Prefixes, func(prefix string) bool { return prefixFold(identity.GID1, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 5
|
||||
}
|
||||
if len(rule.GID2Prefixes) > 0 {
|
||||
if firstMatchingValue(rule.GID2Prefixes, func(prefix string) bool { return prefixFold(identity.GID2, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 4
|
||||
}
|
||||
if len(rule.SPNs) > 0 {
|
||||
if !containsFold(rule.SPNs, identity.SPN) {
|
||||
continue
|
||||
}
|
||||
score += 1 << 1
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
plmn = matchedPLMN
|
||||
name = strings.TrimSpace(rule.Name)
|
||||
}
|
||||
}
|
||||
if bestScore >= 0 && name != "" {
|
||||
countryCode, _ = CountryForMCC(plmn[:3])
|
||||
return plmn, name, countryCode, true
|
||||
}
|
||||
return CarrierForIMSI(imsi)
|
||||
}
|
||||
|
||||
func carrierPLMNCandidates(imsi string, mncLength int) []string {
|
||||
if (mncLength == 2 || mncLength == 3) && len(imsi) >= 3+mncLength {
|
||||
return []string{imsi[:3+mncLength]}
|
||||
}
|
||||
result := make([]string, 0, 2)
|
||||
for _, length := range []int{6, 5} {
|
||||
if len(imsi) >= length {
|
||||
result = append(result, imsi[:length])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstMatchingValue(values []string, match func(string) bool) string {
|
||||
for _, value := range values {
|
||||
if match(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsFold(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prefixFold(value, prefix string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), strings.ToLower(strings.TrimSpace(prefix)))
|
||||
}
|
||||
|
||||
func imsiPatternMatch(imsi, pattern string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if len(imsi) < len(pattern) {
|
||||
return false
|
||||
}
|
||||
for index, value := range pattern {
|
||||
if value != 'x' && value != 'X' && byte(value) != imsi[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -277,6 +277,10 @@ func (manager *Manager) SetFlight(
|
||||
if manager.candidateFor(state).HardwareKind == "pcsc" {
|
||||
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
if result, handled, err := manager.setNativeQMIFlight(ctx, id, state, enabled); handled {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return result, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
|
||||
@@ -2,9 +2,175 @@ package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
type fakeQMIRadioSession struct {
|
||||
mode qmi.OperatingMode
|
||||
getModes []qmi.OperatingMode
|
||||
setModes []qmi.OperatingMode
|
||||
getErr error
|
||||
setErr error
|
||||
closeCount int
|
||||
iccid string
|
||||
iccidErr error
|
||||
}
|
||||
|
||||
func (session *fakeQMIRadioSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) {
|
||||
if len(session.getModes) > 0 {
|
||||
mode := session.getModes[0]
|
||||
session.getModes = session.getModes[1:]
|
||||
return mode, session.getErr
|
||||
}
|
||||
return session.mode, session.getErr
|
||||
}
|
||||
|
||||
func (session *fakeQMIRadioSession) SetOperatingMode(_ context.Context, mode qmi.OperatingMode) error {
|
||||
if session.setErr != nil {
|
||||
return session.setErr
|
||||
}
|
||||
session.setModes = append(session.setModes, mode)
|
||||
session.mode = mode
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeQMIRadioSession) Close() error {
|
||||
session.closeCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeQMIRadioSession) GetICCID(context.Context) (string, error) {
|
||||
return session.iccid, session.iccidErr
|
||||
}
|
||||
|
||||
func newStartedNativeQMITestManager(t *testing.T) (*Manager, *staticOpener, string) {
|
||||
t.Helper()
|
||||
const id = "wwan0"
|
||||
opener := &staticOpener{client: &transcriptClient{}}
|
||||
manager, err := NewManager(Options{
|
||||
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
|
||||
ID: id,
|
||||
Product: "410 WiFi stick",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
NetworkInterface: "wwan0",
|
||||
ATPort: modem.Port{
|
||||
Path: "/dev/wwan0at0",
|
||||
Name: "wwan0at0",
|
||||
Role: modem.PortRoleAT,
|
||||
},
|
||||
}}},
|
||||
Opener: opener,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
if err := manager.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
manager.devices[id].snapshot = &Snapshot{
|
||||
DeviceID: id,
|
||||
OperatingMode: 7,
|
||||
ModeKnown: true,
|
||||
FlightMode: true,
|
||||
RadioOff: true,
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||
return manager, opener, id
|
||||
}
|
||||
|
||||
func TestSetFlightUsesQMIDMSForNativeWWAN(t *testing.T) {
|
||||
manager, atOpener, id := newStartedNativeQMITestManager(t)
|
||||
session := &fakeQMIRadioSession{mode: qmi.ModeOffline}
|
||||
var openedPath string
|
||||
manager.qmiRadioOpener = func(_ context.Context, path string) (qmiRadioSession, error) {
|
||||
openedPath = path
|
||||
return session, nil
|
||||
}
|
||||
|
||||
disabled, err := manager.SetFlight(context.Background(), id, false)
|
||||
if err != nil {
|
||||
t.Fatalf("disable flight mode: %v", err)
|
||||
}
|
||||
if !disabled.Changed || disabled.PreviousMode != 7 || disabled.CurrentMode != 1 ||
|
||||
disabled.FlightMode || disabled.RadioOff {
|
||||
t.Fatalf("disable result = %#v", disabled)
|
||||
}
|
||||
enabled, err := manager.SetFlight(context.Background(), id, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable flight mode: %v", err)
|
||||
}
|
||||
if !enabled.Changed || enabled.PreviousMode != 1 || enabled.CurrentMode != 0 ||
|
||||
!enabled.FlightMode || !enabled.RadioOff {
|
||||
t.Fatalf("enable result = %#v", enabled)
|
||||
}
|
||||
if openedPath != "/dev/wwan0qmi0" {
|
||||
t.Fatalf("QMI path = %q", openedPath)
|
||||
}
|
||||
if len(session.setModes) != 2 || session.setModes[0] != qmi.ModeOnline || session.setModes[1] != qmi.ModeLowPower {
|
||||
t.Fatalf("QMI modes = %v", session.setModes)
|
||||
}
|
||||
if session.closeCount != 2 {
|
||||
t.Fatalf("QMI close count = %d", session.closeCount)
|
||||
}
|
||||
if atOpener.openCount != 0 {
|
||||
t.Fatalf("AT opener used %d times for native QMI flight mode", atOpener.openCount)
|
||||
}
|
||||
entry, err := manager.Get(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Snapshot == nil || entry.Snapshot.OperatingMode != 0 || !entry.Snapshot.FlightMode {
|
||||
t.Fatalf("snapshot = %#v", entry.Snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFlightDoesNotFallBackToUnsupportedATWhenQMIUnavailable(t *testing.T) {
|
||||
manager, atOpener, id := newStartedNativeQMITestManager(t)
|
||||
wantErr := errors.New("QMI DMS unavailable")
|
||||
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
|
||||
if _, err := manager.SetFlight(context.Background(), id, false); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("SetFlight error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if atOpener.openCount != 0 {
|
||||
t.Fatalf("AT opener used %d times after QMI failure", atOpener.openCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFlightWaitsForAsynchronousQMIModeTransition(t *testing.T) {
|
||||
manager, atOpener, id := newStartedNativeQMITestManager(t)
|
||||
session := &fakeQMIRadioSession{
|
||||
mode: qmi.ModeShutdown,
|
||||
getModes: []qmi.OperatingMode{qmi.ModeShutdown, qmi.ModeShutdown, qmi.ModeOnline},
|
||||
}
|
||||
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||
return session, nil
|
||||
}
|
||||
|
||||
result, err := manager.SetFlight(context.Background(), id, false)
|
||||
if err != nil {
|
||||
t.Fatalf("disable flight mode: %v", err)
|
||||
}
|
||||
if !result.Changed || result.PreviousMode != 7 || result.CurrentMode != 1 || result.FlightMode {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if len(session.setModes) != 1 || session.setModes[0] != qmi.ModeOnline {
|
||||
t.Fatalf("QMI modes = %v", session.setModes)
|
||||
}
|
||||
if atOpener.openCount != 0 {
|
||||
t.Fatalf("AT opener used %d times during QMI transition", atOpener.openCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFlightPreservesRawCFUNZero(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 0")},
|
||||
|
||||
@@ -99,6 +99,31 @@ func (manager *Manager) SetNetwork(
|
||||
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
|
||||
}
|
||||
// OpenStick's native WWAN path must drive registration through QMI NAS.
|
||||
// AT+COPS only updates the legacy AT facade on this firmware and can leave
|
||||
// NAS in not-registered-searching, which then makes qmi-network report a
|
||||
// generic-no-service call failure.
|
||||
if request.Enabled && isNativeQMICandidate(candidate) {
|
||||
registrationContext, cancel := context.WithTimeout(ctx, manager.scanTimeout)
|
||||
registrationSession, openErr := manager.openNativeQMIRegistration(registrationContext, candidate)
|
||||
if openErr != nil {
|
||||
cancel()
|
||||
manager.setResult(id, state, nil, openErr)
|
||||
return NetworkResult{}, fmt.Errorf("prepare native QMI registration: %w", openErr)
|
||||
}
|
||||
registrationErr := ensureNativeQMIRegistration(
|
||||
registrationContext,
|
||||
registrationSession,
|
||||
qmiRegistrationRequestAutomatic(),
|
||||
true,
|
||||
)
|
||||
_ = registrationSession.Close()
|
||||
cancel()
|
||||
if registrationErr != nil {
|
||||
manager.setResult(id, state, nil, registrationErr)
|
||||
return NetworkResult{}, registrationErr
|
||||
}
|
||||
}
|
||||
result, err := setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
|
||||
if err != nil && (request.Username != "" || request.Password != "") {
|
||||
// qmi-network output is outside our control and may echo values read
|
||||
@@ -272,6 +297,19 @@ func usbNetModeName(mode int) string {
|
||||
}
|
||||
|
||||
func (manager *Manager) OperatorSelection(ctx context.Context, id string) (OperatorSelection, error) {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if isNativeQMICandidate(candidate) {
|
||||
state.opMu.Lock()
|
||||
defer state.opMu.Unlock()
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
return manager.nativeQMIOperatorSelectionLocked(ctx, candidate)
|
||||
}
|
||||
response, err := manager.ExecuteAT(ctx, id, "AT+COPS?")
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
@@ -335,6 +373,18 @@ func (manager *Manager) SetOperatorSelection(
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if isNativeQMICandidate(candidate) {
|
||||
selection, err := manager.setNativeQMIOperatorSelectionLocked(
|
||||
ctx,
|
||||
candidate,
|
||||
automatic,
|
||||
plmn,
|
||||
accessTechnologyValue,
|
||||
)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return selection, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
@@ -435,6 +485,12 @@ func (manager *Manager) ReRegisterOperator(ctx context.Context, id string) (Oper
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if isNativeQMICandidate(candidate) {
|
||||
selection, err := manager.reRegisterNativeQMIOperatorLocked(ctx, candidate)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return selection, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/qmiport"
|
||||
)
|
||||
|
||||
type qmiRadioSession interface {
|
||||
GetOperatingMode(context.Context) (qmi.OperatingMode, error)
|
||||
SetOperatingMode(context.Context, qmi.OperatingMode) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type qmiRadioSessionOpener func(context.Context, string) (qmiRadioSession, error)
|
||||
|
||||
type nativeQMIICCIDSession interface {
|
||||
GetICCID(context.Context) (string, error)
|
||||
}
|
||||
|
||||
// nativeQMIControl identifies the QMI control node exposed by native WWAN
|
||||
// devices. USB serial modems may also advertise a control path, but only the
|
||||
// wwanN/qmiN pairing is safe to operate through the native QMI path.
|
||||
func (manager *Manager) nativeQMIControl(id string) (string, bool, error) {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
controlDevice := strings.TrimSpace(candidate.QMIControl)
|
||||
deviceID := strings.TrimSpace(candidate.ID)
|
||||
if !nativeQMIControlMatches(deviceID, controlDevice) {
|
||||
return "", false, nil
|
||||
}
|
||||
return controlDevice, true, nil
|
||||
}
|
||||
|
||||
type productionQMIRadioSession struct {
|
||||
client *qmi.Client
|
||||
dms *qmi.DMSService
|
||||
nas *qmi.NASService
|
||||
nasErr error
|
||||
uimMu sync.Mutex
|
||||
uim *qmi.UIMService
|
||||
lease *qmiport.Lease
|
||||
}
|
||||
|
||||
// The native WWAN path uses the same QMI NAS client for radio wake-up,
|
||||
// operator selection, and registration. Keep these methods optional on the
|
||||
// qmiRadioSession interface so the older transcript-backed tests and AT-only
|
||||
// devices do not need to grow a fake NAS implementation.
|
||||
func (session *productionQMIRadioSession) nasService() (*qmi.NASService, error) {
|
||||
if session == nil {
|
||||
return nil, errors.New("QMI NAS session is unavailable")
|
||||
}
|
||||
if session.nas == nil {
|
||||
if session.nasErr != nil {
|
||||
return nil, session.nasErr
|
||||
}
|
||||
return nil, errors.New("QMI NAS session is unavailable")
|
||||
}
|
||||
return session.nas, nil
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) GetServingSystem(ctx context.Context) (*qmi.ServingSystem, error) {
|
||||
nas, err := session.nasService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nas.GetServingSystem(ctx)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) GetSystemSelectionPreference(ctx context.Context) (*qmi.SystemSelectionPreference, error) {
|
||||
nas, err := session.nasService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nas.GetSystemSelectionPreference(ctx)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) SetSystemSelectionPreference(ctx context.Context, pref qmi.SystemSelectionPreference) error {
|
||||
nas, err := session.nasService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nas.SetSystemSelectionPreference(ctx, pref)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) InitiateNetworkRegister(ctx context.Context, req qmi.NASInitiateNetworkRegisterRequest) error {
|
||||
nas, err := session.nasService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nas.InitiateNetworkRegister(ctx, req)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) ForceNetworkSearch(ctx context.Context) error {
|
||||
nas, err := session.nasService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nas.ForceNetworkSearch(ctx)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) AttachDetach(ctx context.Context, attached bool) error {
|
||||
nas, err := session.nasService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nas.AttachDetach(ctx, attached)
|
||||
}
|
||||
|
||||
// openQMIRadioSession controls native WWAN radios through QMI DMS. OpenStick
|
||||
// 410 firmware rejects AT+CFUN=1 even though the equivalent DMS online request
|
||||
// is supported, so native WWAN devices must not fall back to the AT path.
|
||||
func openQMIRadioSession(ctx context.Context, controlDevice string) (qmiRadioSession, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
openContext, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
lease, err := qmiport.Acquire(openContext, controlDevice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := qmi.DefaultClientOptions()
|
||||
opts.UseProxy = true
|
||||
opts.Logf = func(qmi.ClientLogLevel, string, ...any) {}
|
||||
client, err := qmi.NewClientWithOptions(openContext, controlDevice, opts)
|
||||
if err != nil {
|
||||
lease.Release()
|
||||
return nil, err
|
||||
}
|
||||
dms, err := qmi.NewDMSServiceWithContext(openContext, client)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
lease.Release()
|
||||
return nil, err
|
||||
}
|
||||
// NAS is optional for ordinary radio controls. Some firmware exposes DMS
|
||||
// but rejects NAS client allocation; keep radio control usable and report
|
||||
// that limitation only to native registration/RF queries.
|
||||
nas, nasErr := qmi.NewNASServiceWithContext(openContext, client)
|
||||
return &productionQMIRadioSession{
|
||||
client: client,
|
||||
dms: dms,
|
||||
nas: nas,
|
||||
nasErr: nasErr,
|
||||
lease: lease,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) GetICCID(ctx context.Context) (string, error) {
|
||||
if session == nil || session.client == nil {
|
||||
return "", errors.New("QMI UIM session is unavailable")
|
||||
}
|
||||
session.uimMu.Lock()
|
||||
defer session.uimMu.Unlock()
|
||||
if session.uim == nil {
|
||||
uim, err := qmi.NewUIMServiceWithContext(ctx, session.client)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
session.uim = uim
|
||||
}
|
||||
return session.uim.GetICCID(ctx)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) GetOperatingMode(ctx context.Context) (qmi.OperatingMode, error) {
|
||||
return session.dms.GetOperatingMode(ctx)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) SetOperatingMode(ctx context.Context, mode qmi.OperatingMode) error {
|
||||
return session.dms.SetOperatingMode(ctx, mode)
|
||||
}
|
||||
|
||||
func (session *productionQMIRadioSession) Close() error {
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
var closeErrors []error
|
||||
session.uimMu.Lock()
|
||||
if session.uim != nil {
|
||||
closeErrors = append(closeErrors, session.uim.Close())
|
||||
session.uim = nil
|
||||
}
|
||||
session.uimMu.Unlock()
|
||||
if session.dms != nil {
|
||||
closeErrors = append(closeErrors, session.dms.Close())
|
||||
session.dms = nil
|
||||
}
|
||||
if session.nas != nil {
|
||||
closeErrors = append(closeErrors, session.nas.Close())
|
||||
session.nas = nil
|
||||
}
|
||||
if session.client != nil {
|
||||
closeErrors = append(closeErrors, session.client.Close())
|
||||
session.client = nil
|
||||
}
|
||||
if session.lease != nil {
|
||||
session.lease.Release()
|
||||
session.lease = nil
|
||||
}
|
||||
return errors.Join(closeErrors...)
|
||||
}
|
||||
|
||||
func (manager *Manager) setNativeQMIFlight(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
state *managedDevice,
|
||||
enabled bool,
|
||||
) (FlightResult, bool, error) {
|
||||
controlDevice, native, err := manager.nativeQMIControl(id)
|
||||
if err != nil {
|
||||
return FlightResult{}, true, err
|
||||
}
|
||||
if !native {
|
||||
return FlightResult{}, false, nil
|
||||
}
|
||||
if manager.qmiRadioOpener == nil {
|
||||
return FlightResult{}, true, errors.New("QMI DMS radio control is unavailable")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
openContext, cancelOpen := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||
session, err := manager.qmiRadioOpener(openContext, controlDevice)
|
||||
cancelOpen()
|
||||
if err != nil {
|
||||
return FlightResult{}, true, fmt.Errorf("open QMI DMS radio control: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
readContext, cancelRead := manager.withTimeout(ctx, manager.commandTimeout)
|
||||
previousQMI, err := session.GetOperatingMode(readContext)
|
||||
cancelRead()
|
||||
if err != nil {
|
||||
return FlightResult{}, true, fmt.Errorf("read QMI operating mode: %w", err)
|
||||
}
|
||||
previous := qmiModeAsCFUN(previousQMI)
|
||||
targetQMI := previousQMI
|
||||
if enabled {
|
||||
if !isQMIRadioOffMode(previousQMI) {
|
||||
targetQMI = qmi.ModeLowPower
|
||||
}
|
||||
} else if previousQMI != qmi.ModeOnline {
|
||||
targetQMI = qmi.ModeOnline
|
||||
}
|
||||
changed := targetQMI != previousQMI
|
||||
if changed {
|
||||
setContext, cancelSet := manager.withTimeout(ctx, manager.commandTimeout)
|
||||
err = session.SetOperatingMode(setContext, targetQMI)
|
||||
cancelSet()
|
||||
if err != nil {
|
||||
return FlightResult{
|
||||
PreviousMode: previous,
|
||||
CurrentMode: previous,
|
||||
FlightMode: isQMIRadioOffMode(previousQMI),
|
||||
RadioOff: isQMIRadioOffMode(previousQMI),
|
||||
}, true, fmt.Errorf("set QMI operating mode: %w", err)
|
||||
}
|
||||
}
|
||||
currentQMI, err := manager.waitForQMIRadioState(ctx, session, enabled, targetQMI)
|
||||
if err != nil {
|
||||
currentRadioOff := isQMIRadioOffMode(currentQMI)
|
||||
return FlightResult{
|
||||
PreviousMode: previous,
|
||||
CurrentMode: qmiModeAsCFUN(currentQMI),
|
||||
Changed: changed,
|
||||
FlightMode: currentRadioOff,
|
||||
RadioOff: currentRadioOff,
|
||||
}, true, err
|
||||
}
|
||||
current := qmiModeAsCFUN(currentQMI)
|
||||
currentRadioOff := isQMIRadioOffMode(currentQMI)
|
||||
manager.updateSnapshotMode(id, state, current)
|
||||
if !enabled && !currentRadioOff {
|
||||
// DMS Online is only the radio half of the recovery. Continue with a
|
||||
// background NAS registration/PS-attach reconcile after the flight-mode
|
||||
// transition without holding the radio QMI session open.
|
||||
manager.startNativeQMIRegistrationReconcile(id)
|
||||
}
|
||||
return FlightResult{
|
||||
PreviousMode: previous,
|
||||
CurrentMode: current,
|
||||
Changed: changed,
|
||||
FlightMode: currentRadioOff,
|
||||
RadioOff: currentRadioOff,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) waitForQMIRadioState(
|
||||
ctx context.Context,
|
||||
session qmiRadioSession,
|
||||
radioOff bool,
|
||||
fallback qmi.OperatingMode,
|
||||
) (qmi.OperatingMode, error) {
|
||||
verifyTimeout := manager.commandTimeout * 2
|
||||
if verifyTimeout < 5*time.Second {
|
||||
verifyTimeout = 5 * time.Second
|
||||
}
|
||||
verifyContext, cancel := manager.withTimeout(ctx, verifyTimeout)
|
||||
defer cancel()
|
||||
current := fallback
|
||||
var lastErr error
|
||||
for {
|
||||
mode, err := session.GetOperatingMode(verifyContext)
|
||||
if err == nil {
|
||||
current = mode
|
||||
lastErr = nil
|
||||
if qmiModeMatchesFlight(mode, radioOff) {
|
||||
return mode, nil
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
timer := time.NewTimer(250 * time.Millisecond)
|
||||
select {
|
||||
case <-verifyContext.Done():
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return current, fmt.Errorf("verify QMI operating mode: %w", lastErr)
|
||||
}
|
||||
return current, fmt.Errorf(
|
||||
"QMI operating mode did not reach requested radio state (mode %d): %w",
|
||||
current,
|
||||
verifyContext.Err(),
|
||||
)
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func qmiModeMatchesFlight(mode qmi.OperatingMode, radioOff bool) bool {
|
||||
if radioOff {
|
||||
return isQMIRadioOffMode(mode)
|
||||
}
|
||||
return mode == qmi.ModeOnline
|
||||
}
|
||||
|
||||
func isQMIRadioOffMode(mode qmi.OperatingMode) bool {
|
||||
switch mode {
|
||||
case qmi.ModeLowPower, qmi.ModeOffline, qmi.ModeShutdown, qmi.ModePersistLow, qmi.ModeOnlyLowPower:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FlightResult and Snapshot historically expose AT+CFUN values. Preserve that
|
||||
// API contract while sourcing the real radio state from QMI DMS.
|
||||
func qmiModeAsCFUN(mode qmi.OperatingMode) int {
|
||||
switch mode {
|
||||
case qmi.ModeOnline:
|
||||
return 1
|
||||
case qmi.ModeLowPower, qmi.ModePersistLow:
|
||||
return 0
|
||||
case qmi.ModeOffline, qmi.ModeShutdown, qmi.ModeOnlyLowPower:
|
||||
return 7
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func (manager *Manager) readNativeQMIICCID(ctx context.Context, candidate modem.Candidate) (string, error) {
|
||||
if manager == nil || manager.qmiRadioOpener == nil {
|
||||
return "", errors.New("QMI UIM ICCID reader is unavailable")
|
||||
}
|
||||
if candidate.QMIControl == "" {
|
||||
return "", errors.New("QMI UIM control device is unavailable")
|
||||
}
|
||||
session, err := manager.qmiRadioOpener(ctx, candidate.QMIControl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open QMI UIM control: %w", err)
|
||||
}
|
||||
if session == nil {
|
||||
return "", errors.New("QMI UIM control returned an empty session")
|
||||
}
|
||||
defer session.Close()
|
||||
reader, ok := session.(nativeQMIICCIDSession)
|
||||
if !ok {
|
||||
return "", errors.New("QMI session does not expose UIM ICCID reading")
|
||||
}
|
||||
value, err := reader.GetICCID(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read EF_ICCID: %w", err)
|
||||
}
|
||||
iccid := parseICCIDIdentifier(modem.Response{Lines: []string{value}}, nil, 18, 22)
|
||||
if iccid == "" {
|
||||
return "", errors.New("QMI UIM returned an invalid ICCID")
|
||||
}
|
||||
return iccid, nil
|
||||
}
|
||||
@@ -38,9 +38,14 @@ type Manager struct {
|
||||
smsTimeout time.Duration
|
||||
scanTimeout time.Duration
|
||||
cardReaders *pcsc.Service
|
||||
started bool
|
||||
devices map[string]*managedDevice
|
||||
ussdSessions map[string]ussdSession
|
||||
|
||||
qmiRadioOpener qmiRadioSessionOpener
|
||||
nativeQMIRegistrationMu sync.Mutex
|
||||
nativeQMIRegistrationInFlight map[string]struct{}
|
||||
|
||||
started bool
|
||||
devices map[string]*managedDevice
|
||||
ussdSessions map[string]ussdSession
|
||||
}
|
||||
|
||||
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
|
||||
@@ -115,6 +120,10 @@ func NewManager(options Options) (*Manager, error) {
|
||||
smsTimeout: options.SMSTimeout,
|
||||
scanTimeout: options.ScanTimeout,
|
||||
cardReaders: options.CardReaders,
|
||||
|
||||
qmiRadioOpener: openQMIRadioSession,
|
||||
nativeQMIRegistrationInFlight: make(map[string]struct{}),
|
||||
|
||||
devices: make(map[string]*managedDevice),
|
||||
ussdSessions: make(map[string]ussdSession),
|
||||
esimRecoveries: make(map[string]chan struct{}),
|
||||
@@ -232,7 +241,20 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
state.opMu.Unlock()
|
||||
}
|
||||
manager.resetChangedClients()
|
||||
return manager.List(), nil
|
||||
|
||||
// List retains previously discovered devices so configured hardware can be
|
||||
// rendered as offline after it is unplugged. Discover, however, is a fresh
|
||||
// physical scan and must only return devices that are present now. Returning
|
||||
// the retained entries here allowed an unplugged modem to be selected and
|
||||
// added again from the device discovery screen.
|
||||
devices := manager.List()
|
||||
present := devices[:0]
|
||||
for _, entry := range devices {
|
||||
if entry.Discovered {
|
||||
present = append(present, entry)
|
||||
}
|
||||
}
|
||||
return present, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) resetChangedClients() {
|
||||
@@ -411,7 +433,14 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return Snapshot{}, err
|
||||
}
|
||||
previousICCID := state.lastICCID
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
|
||||
var previousSnapshot *Snapshot
|
||||
manager.mu.RLock()
|
||||
if state.snapshot != nil {
|
||||
copy := *state.snapshot
|
||||
previousSnapshot = ©
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, previousSnapshot, client)
|
||||
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
|
||||
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
}
|
||||
@@ -447,6 +476,7 @@ func (manager *Manager) refreshCardReader(ctx context.Context, id string, state
|
||||
result.ICCID = card.Identity.ICCID
|
||||
result.IMSI = card.Identity.IMSI
|
||||
result.SPN = card.Identity.SPN
|
||||
result.MNCLength = card.Identity.MNCLength
|
||||
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
|
||||
state.lastICCID = result.ICCID
|
||||
}
|
||||
|
||||
@@ -45,6 +45,29 @@ func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDiscoverReturnsOnlyCurrentlyPresentDevices(t *testing.T) {
|
||||
manager, id := newStartedTestManager(t, nil)
|
||||
if devices := manager.List(); len(devices) != 1 || devices[0].ID != id || !devices[0].Discovered {
|
||||
t.Fatalf("initial devices = %#v", devices)
|
||||
}
|
||||
|
||||
manager.discoverer = staticDiscoverer{}
|
||||
present, err := manager.Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover after unplug: %v", err)
|
||||
}
|
||||
if len(present) != 0 {
|
||||
t.Fatalf("present devices after unplug = %#v, want none", present)
|
||||
}
|
||||
|
||||
// The retained entry is still available to the configured-device dashboard,
|
||||
// but is explicitly offline and cannot be offered by fresh discovery.
|
||||
retained := manager.List()
|
||||
if len(retained) != 1 || retained[0].ID != id || retained[0].Discovered {
|
||||
t.Fatalf("retained devices after unplug = %#v", retained)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{
|
||||
@@ -64,6 +87,12 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
|
||||
{command: "AT+CIMI", response: okResponse("460001234567890")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
|
||||
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020002FFFF"`)},
|
||||
{command: "AT+CRSM=176,28478,0,0,2", response: okResponse(`+CRSM: 144,0,"0102"`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
||||
{
|
||||
command: `AT+QENG="servingcell"`,
|
||||
@@ -112,7 +141,8 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
}
|
||||
if snapshot.IMEI != "867123456789012" ||
|
||||
snapshot.ICCID != "8986001234567890123" ||
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" ||
|
||||
snapshot.MNCLength != 2 || snapshot.GID1 != "0102" || snapshot.GID2 != "" {
|
||||
t.Fatalf("subscriber identifiers = %#v", snapshot)
|
||||
}
|
||||
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
|
||||
@@ -134,6 +164,59 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "ATI", response: okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410")},
|
||||
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
|
||||
{command: "AT+CCID", response: modem.Response{Final: "ERROR"}, err: errors.New("CCID unsupported")},
|
||||
{command: "AT+QCCID", response: modem.Response{Final: "ERROR"}, err: errors.New("QCCID unsupported")},
|
||||
{command: "AT+CIMI", response: okResponse("234159611274418")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
|
||||
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,2")},
|
||||
{command: "AT+CGSN", response: okResponse("867123456789012")},
|
||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
|
||||
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
|
||||
}}
|
||||
manager, err := NewManager(Options{
|
||||
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
|
||||
ID: "mhi-wwan0",
|
||||
Product: "PCIe/MHI WWAN modem",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
NetworkInterface: "wwan0",
|
||||
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
|
||||
}}},
|
||||
Opener: &staticOpener{client: client},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||
return &fakeQMIRadioSession{iccid: "89441000400316034372"}, nil
|
||||
}
|
||||
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
if snapshot.ICCID != "89441000400316034372" || !snapshot.SIMReady {
|
||||
t.Fatalf("native QMI identity = %#v", snapshot)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestParseSPNASCIIAndUCS2(t *testing.T) {
|
||||
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
|
||||
t.Fatalf("ASCII SPN = %q", got)
|
||||
@@ -198,6 +281,12 @@ func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
|
||||
{command: "AT+CFUN=4", response: okResponse()},
|
||||
{command: "AT+CIMI", response: okResponse("234150000000002")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
|
||||
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28478,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
|
||||
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,6 +22,13 @@ var BlockedMCCs = map[string]string{
|
||||
// code. The MCC is the leading three digits and the MNC the following two or
|
||||
// three. Empty strings are returned for an unusable IMSI.
|
||||
func CardMCCMNC(imsi string) (mcc string, mnc string) {
|
||||
return CardMCCMNCWithLength(imsi, 0)
|
||||
}
|
||||
|
||||
// CardMCCMNCWithLength uses the MNC length advertised by EF_AD when available.
|
||||
// Without it the historical three-digit behavior is retained for callers that
|
||||
// have only an IMSI.
|
||||
func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
|
||||
digits := strings.TrimSpace(imsi)
|
||||
if len(digits) < 5 ||
|
||||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
||||
@@ -29,8 +36,11 @@ func CardMCCMNC(imsi string) (mcc string, mnc string) {
|
||||
}
|
||||
mcc = digits[:3]
|
||||
mnc = digits[3:]
|
||||
if len(mnc) > 3 {
|
||||
mnc = mnc[:3]
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
mncLength = 3
|
||||
}
|
||||
if len(mnc) > mncLength {
|
||||
mnc = mnc[:mncLength]
|
||||
}
|
||||
return mcc, mnc
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestCardMCCMNC(t *testing.T) {
|
||||
if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" {
|
||||
t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc)
|
||||
}
|
||||
if mcc, mnc := CardMCCMNCWithLength("454006395879502", 2); mcc != "454" || mnc != "00" {
|
||||
t.Fatalf("CardMCCMNCWithLength = (%q, %q), want (454, 00)", mcc, mnc)
|
||||
}
|
||||
for _, bad := range []string{"", "4600", "4600X1234"} {
|
||||
if mcc, _ := CardMCCMNC(bad); mcc != "" {
|
||||
t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc)
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
// nativeQMIRegistrationSession is the QMI NAS control surface used by
|
||||
// OpenStick WWAN devices. It deliberately stays separate from
|
||||
// qmiRadioSession so AT-only devices and existing radio-control fakes do not
|
||||
// acquire a mandatory NAS implementation.
|
||||
type nativeQMIRegistrationSession interface {
|
||||
qmiRadioSession
|
||||
GetServingSystem(context.Context) (*qmi.ServingSystem, error)
|
||||
GetSystemSelectionPreference(context.Context) (*qmi.SystemSelectionPreference, error)
|
||||
SetSystemSelectionPreference(context.Context, qmi.SystemSelectionPreference) error
|
||||
InitiateNetworkRegister(context.Context, qmi.NASInitiateNetworkRegisterRequest) error
|
||||
ForceNetworkSearch(context.Context) error
|
||||
AttachDetach(context.Context, bool) error
|
||||
}
|
||||
|
||||
const (
|
||||
nativeQMIRegistrationPollInterval = 2 * time.Second
|
||||
nativeQMIRegistrationMaxAttempts = 45
|
||||
nativeQMIRegistrationRadioCycleAfterAttempts = 30
|
||||
nativeQMIRegistrationUnsupportedCycleAfterTries = 3
|
||||
nativeQMIRegistrationBackgroundTimeout = 45 * time.Second
|
||||
)
|
||||
|
||||
func isNativeQMICandidate(candidate modem.Candidate) bool {
|
||||
deviceID := strings.TrimSpace(candidate.ID)
|
||||
control := strings.TrimSpace(candidate.QMIControl)
|
||||
return nativeQMIControlMatches(deviceID, control)
|
||||
}
|
||||
|
||||
func nativeQMIControlMatches(deviceID, control string) bool {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
control = strings.TrimSpace(control)
|
||||
if deviceID == "" || control == "" {
|
||||
return false
|
||||
}
|
||||
prefix := ""
|
||||
switch {
|
||||
case strings.HasPrefix(deviceID, "wwan"):
|
||||
prefix = deviceID + "qmi"
|
||||
case strings.HasPrefix(deviceID, "mhi-wwan"):
|
||||
prefix = "wwan" + strings.TrimPrefix(deviceID, "mhi-wwan") + "qmi"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(filepath.Base(control), prefix)
|
||||
}
|
||||
|
||||
func (manager *Manager) openNativeQMIRegistration(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
) (nativeQMIRegistrationSession, error) {
|
||||
if manager == nil || manager.qmiRadioOpener == nil {
|
||||
return nil, errors.New("QMI NAS registration is unavailable")
|
||||
}
|
||||
control := strings.TrimSpace(candidate.QMIControl)
|
||||
if control == "" {
|
||||
return nil, errors.New("QMI NAS registration control device is unavailable")
|
||||
}
|
||||
session, err := manager.qmiRadioOpener(ctx, control)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nas, ok := session.(nativeQMIRegistrationSession)
|
||||
if !ok {
|
||||
_ = session.Close()
|
||||
return nil, errors.New("QMI radio session does not expose NAS registration control")
|
||||
}
|
||||
return nas, nil
|
||||
}
|
||||
|
||||
// startNativeQMIRegistrationReconcile continues registration after a radio
|
||||
// transition. Bringing DMS online only proves that the RF switch completed;
|
||||
// NAS may still report searching or PS detached seconds later, so the
|
||||
// registration sequence continues after SetFlight returns. The per-device
|
||||
// guard prevents repeated UI/poll callbacks from opening competing sessions.
|
||||
func (manager *Manager) startNativeQMIRegistrationReconcile(id string) bool {
|
||||
if manager == nil {
|
||||
return false
|
||||
}
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if !isNativeQMICandidate(candidate) {
|
||||
return false
|
||||
}
|
||||
manager.nativeQMIRegistrationMu.Lock()
|
||||
if _, running := manager.nativeQMIRegistrationInFlight[id]; running {
|
||||
manager.nativeQMIRegistrationMu.Unlock()
|
||||
return false
|
||||
}
|
||||
manager.nativeQMIRegistrationInFlight[id] = struct{}{}
|
||||
manager.nativeQMIRegistrationMu.Unlock()
|
||||
go func() {
|
||||
defer func() {
|
||||
manager.nativeQMIRegistrationMu.Lock()
|
||||
delete(manager.nativeQMIRegistrationInFlight, id)
|
||||
manager.nativeQMIRegistrationMu.Unlock()
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nativeQMIRegistrationBackgroundTimeout)
|
||||
defer cancel()
|
||||
_, _ = manager.ReRegisterOperator(ctx, id)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func qmiOperatorSelectionFromPreference(pref *qmi.SystemSelectionPreference) (OperatorSelection, error) {
|
||||
if pref == nil {
|
||||
return OperatorSelection{}, errors.New("QMI returned an empty system-selection preference")
|
||||
}
|
||||
accessTechnology := qmiAccessTechnologyFromModePreference(pref.ModePreference)
|
||||
if pref.HasManualNetworkSelection {
|
||||
mcc := fmt.Sprintf("%03d", pref.ManualNetworkSelection.MCC)
|
||||
mncWidth := 2
|
||||
if pref.ManualNetworkSelection.IncludesPCSDigit {
|
||||
mncWidth = 3
|
||||
}
|
||||
mnc := fmt.Sprintf("%0*d", mncWidth, pref.ManualNetworkSelection.MNC)
|
||||
return OperatorSelection{
|
||||
Mode: 1,
|
||||
Format: 2,
|
||||
Operator: mcc + mnc,
|
||||
AccessTechnology: accessTechnology,
|
||||
}, nil
|
||||
}
|
||||
return OperatorSelection{Mode: 0, AccessTechnology: accessTechnology}, nil
|
||||
}
|
||||
|
||||
func qmiManualRegisterRequest(
|
||||
plmn string,
|
||||
accessTechnologyValue *int,
|
||||
) (qmi.NASInitiateNetworkRegisterRequest, error) {
|
||||
mcc, mnc, includesPCSDigit, err := qmiPLMNParts(plmn)
|
||||
if err != nil {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{}, err
|
||||
}
|
||||
rat := uint8(0)
|
||||
if accessTechnologyValue != nil {
|
||||
if *accessTechnologyValue < 0 || *accessTechnologyValue > 9 {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{}, errors.New("invalid operator access technology")
|
||||
}
|
||||
rat = qmiRATFromATCode(*accessTechnologyValue)
|
||||
if rat == 0 {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{}, errors.New("unsupported operator access technology")
|
||||
}
|
||||
}
|
||||
return qmi.NASInitiateNetworkRegisterRequest{
|
||||
Mode: qmi.NASNetworkRegisterManual,
|
||||
MCC: mcc,
|
||||
MNC: mnc,
|
||||
IncludesPCSDigit: includesPCSDigit,
|
||||
RadioAccessTech: rat,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func qmiPLMNParts(plmn string) (mcc, mnc uint16, includesPCSDigit bool, err error) {
|
||||
plmn = strings.TrimSpace(plmn)
|
||||
if !decimalPLMN(plmn) {
|
||||
return 0, 0, false, errors.New("operator PLMN must contain 5 or 6 digits")
|
||||
}
|
||||
mccValue, parseErr := strconv.ParseUint(plmn[:3], 10, 16)
|
||||
if parseErr != nil {
|
||||
return 0, 0, false, fmt.Errorf("parse operator MCC: %w", parseErr)
|
||||
}
|
||||
mncValue, parseErr := strconv.ParseUint(plmn[3:], 10, 16)
|
||||
if parseErr != nil {
|
||||
return 0, 0, false, fmt.Errorf("parse operator MNC: %w", parseErr)
|
||||
}
|
||||
return uint16(mccValue), uint16(mncValue), len(plmn) == 6, nil
|
||||
}
|
||||
|
||||
func qmiManualSelectionPreference(plmn string) (qmi.SystemSelectionPreference, qmi.ManualNetworkSelection, error) {
|
||||
return qmiManualSelectionPreferenceWithRAT(plmn, nil)
|
||||
}
|
||||
|
||||
func qmiManualSelectionPreferenceWithRAT(
|
||||
plmn string,
|
||||
accessTechnologyValue *int,
|
||||
) (qmi.SystemSelectionPreference, qmi.ManualNetworkSelection, error) {
|
||||
mcc, mnc, includesPCSDigit, err := qmiPLMNParts(plmn)
|
||||
if err != nil {
|
||||
return qmi.SystemSelectionPreference{}, qmi.ManualNetworkSelection{}, err
|
||||
}
|
||||
selection := qmi.ManualNetworkSelection{
|
||||
MCC: mcc,
|
||||
MNC: mnc,
|
||||
IncludesPCSDigit: includesPCSDigit,
|
||||
}
|
||||
pref := qmi.SystemSelectionPreference{
|
||||
NetworkSelectionPreference: qmi.NASNetworkSelectionManual,
|
||||
HasNetworkSelectionPreference: true,
|
||||
ManualNetworkSelection: selection,
|
||||
HasManualNetworkSelection: true,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}
|
||||
if accessTechnologyValue != nil {
|
||||
modePreference, ok := qmiModePreferenceFromATCode(*accessTechnologyValue)
|
||||
if !ok {
|
||||
return qmi.SystemSelectionPreference{}, qmi.ManualNetworkSelection{}, errors.New("unsupported operator access technology")
|
||||
}
|
||||
pref.ModePreference = modePreference
|
||||
pref.HasModePreference = true
|
||||
}
|
||||
return pref, selection, nil
|
||||
}
|
||||
|
||||
func qmiRATFromATCode(value int) uint8 {
|
||||
switch value {
|
||||
case 0, 3: // GSM / EDGE
|
||||
return 0x04
|
||||
case 2, 4, 5, 6: // UTRAN / HSDPA / HSUPA / HSPA
|
||||
return 0x05
|
||||
case 7: // LTE
|
||||
return 0x08
|
||||
case 9: // NR5G
|
||||
return 0x0C
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func qmiModePreferenceFromATCode(value int) (uint16, bool) {
|
||||
switch value {
|
||||
case 0, 3: // GSM / EDGE
|
||||
return qmi.NASRatModePreferenceGSM, true
|
||||
case 2, 4, 5, 6: // UTRAN / HSDPA / HSUPA / HSPA
|
||||
return qmi.NASRatModePreferenceUMTS, true
|
||||
case 7: // LTE
|
||||
return qmi.NASRatModePreferenceLTE, true
|
||||
case 9: // NR5G
|
||||
return qmi.NASRatModePreferenceNR5G, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRATFromServingRadioInterface(value uint8) uint8 {
|
||||
switch value {
|
||||
case 4, 5, 8:
|
||||
return value
|
||||
case 10: // NAS serving-system NR5G value
|
||||
return 0x0C
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRATFromModePreference(value uint16) uint8 {
|
||||
switch {
|
||||
case value&qmi.NASRatModePreferenceNR5G != 0:
|
||||
return 0x0C
|
||||
case value&qmi.NASRatModePreferenceLTE != 0:
|
||||
return 0x08
|
||||
case value&qmi.NASRatModePreferenceUMTS != 0:
|
||||
return 0x05
|
||||
case value&qmi.NASRatModePreferenceGSM != 0:
|
||||
return 0x04
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func qmiAccessTechnologyFromModePreference(value uint16) string {
|
||||
switch {
|
||||
case value&qmi.NASRatModePreferenceNR5G != 0:
|
||||
return "NR5G"
|
||||
case value&qmi.NASRatModePreferenceLTE != 0:
|
||||
return "LTE"
|
||||
case value&qmi.NASRatModePreferenceUMTS != 0:
|
||||
return "UTRAN"
|
||||
case value&qmi.NASRatModePreferenceGSM != 0:
|
||||
return "GSM"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRegistrationRequestAutomatic() qmi.NASInitiateNetworkRegisterRequest {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{
|
||||
Mode: qmi.NASNetworkRegisterAutomatic,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}
|
||||
}
|
||||
|
||||
func qmiSelectionAutomaticPreference() qmi.SystemSelectionPreference {
|
||||
return qmi.SystemSelectionPreference{
|
||||
NetworkSelectionPreference: qmi.NASNetworkSelectionAutomatic,
|
||||
HasNetworkSelectionPreference: true,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsupportedQMIRegistrationCommand(err error, messageID uint16) bool {
|
||||
qmiErr := qmi.GetQMIError(err)
|
||||
if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != messageID {
|
||||
return false
|
||||
}
|
||||
switch qmiErr.ErrorCode {
|
||||
case qmi.QMIErrMalformedMsg,
|
||||
qmi.QMIErrInvalidRegisterAction,
|
||||
qmi.QMIErrNoEffect,
|
||||
qmi.QMIErrNotSupported,
|
||||
qmi.QMIErrInvalidQmiCmd,
|
||||
qmi.QMIErrOpDeviceUnsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsupportedQMIForceSearch(err error) bool {
|
||||
qmiErr := qmi.GetQMIError(err)
|
||||
if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != qmi.NASForceNetworkSearch {
|
||||
return false
|
||||
}
|
||||
return qmiErr.ErrorCode == qmi.QMIErrNotSupported ||
|
||||
qmiErr.ErrorCode == qmi.QMIErrInvalidQmiCmd ||
|
||||
qmiErr.ErrorCode == qmi.QMIErrOpDeviceUnsupported
|
||||
}
|
||||
|
||||
func isUnsupportedQMISelectionCommand(err error) bool {
|
||||
qmiErr := qmi.GetQMIError(err)
|
||||
if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != qmi.NASSetSystemSelectionPreference {
|
||||
return false
|
||||
}
|
||||
switch qmiErr.ErrorCode {
|
||||
case qmi.QMIErrMalformedMsg,
|
||||
qmi.QMIErrInvalidRegisterAction,
|
||||
qmi.QMIErrNoEffect,
|
||||
qmi.QMIErrNotSupported,
|
||||
qmi.QMIErrInvalidQmiCmd,
|
||||
qmi.QMIErrOpDeviceUnsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRegistrationStateRegistered(state qmi.RegistrationState) bool {
|
||||
return state == qmi.RegStateRegistered || state == qmi.RegStateRoaming
|
||||
}
|
||||
|
||||
func nativeQMIRegistrationRadioCycleThreshold(forceSearchUnsupported bool) int {
|
||||
if forceSearchUnsupported {
|
||||
return nativeQMIRegistrationUnsupportedCycleAfterTries
|
||||
}
|
||||
return nativeQMIRegistrationRadioCycleAfterAttempts
|
||||
}
|
||||
|
||||
// triggerNativeQMIManualRegistration applies the manual preference that was
|
||||
// written by the caller and starts a fresh NAS search. On the OpenStick 410
|
||||
// firmware, NAS_FORCE_NETWORK_SEARCH is the reliable trigger; sending
|
||||
// NAS_INITIATE_NETWORK_REGISTER with RadioAccessTech=0 is rejected as an
|
||||
// invalid profile. Older firmware may not expose force-search, so fall back
|
||||
// to an explicit RAT (or the current serving RAT) when that command is not
|
||||
// supported.
|
||||
func triggerNativeQMIManualRegistration(
|
||||
ctx context.Context,
|
||||
session nativeQMIRegistrationSession,
|
||||
request *qmi.NASInitiateNetworkRegisterRequest,
|
||||
serving *qmi.ServingSystem,
|
||||
) (forceSearchIssued bool, forceSearchUnsupported bool, err error) {
|
||||
if request == nil {
|
||||
return false, false, errors.New("QMI manual registration request is unavailable")
|
||||
}
|
||||
if err := session.ForceNetworkSearch(ctx); err == nil {
|
||||
return true, false, nil
|
||||
} else if !isUnsupportedQMIForceSearch(err) {
|
||||
return false, false, fmt.Errorf("force QMI network search: %w", err)
|
||||
}
|
||||
|
||||
forceSearchUnsupported = true
|
||||
if request.RadioAccessTech == 0 && serving != nil {
|
||||
request.RadioAccessTech = qmiRATFromServingRadioInterface(serving.RadioInterface)
|
||||
}
|
||||
if request.RadioAccessTech == 0 {
|
||||
return false, true, errors.New("QMI manual registration requires a supported radio access technology")
|
||||
}
|
||||
if err := session.InitiateNetworkRegister(ctx, *request); err != nil {
|
||||
return false, true, fmt.Errorf("initiate manual QMI network registration: %w", err)
|
||||
}
|
||||
return false, true, nil
|
||||
}
|
||||
|
||||
// ensureNativeQMIRegistration runs the NAS registration sequence used on
|
||||
// OpenStick. The modem's AT+COPS surface on this firmware only changes
|
||||
// presentation; it does not reliably drive this NAS state machine.
|
||||
func ensureNativeQMIRegistration(
|
||||
ctx context.Context,
|
||||
session nativeQMIRegistrationSession,
|
||||
request qmi.NASInitiateNetworkRegisterRequest,
|
||||
setAutomatic bool,
|
||||
) error {
|
||||
return ensureNativeQMIRegistrationForTarget(ctx, session, request, setAutomatic, nil)
|
||||
}
|
||||
|
||||
// ensureNativeQMIRegistrationForTarget is the manual-lock variant of the
|
||||
// registration sequence. A modem can remain registered on the old PLMN while
|
||||
// it processes a new manual request, so a successful registered/PS-attached
|
||||
// state is only authoritative when it is on the requested PLMN.
|
||||
func ensureNativeQMIRegistrationForTarget(
|
||||
ctx context.Context,
|
||||
session nativeQMIRegistrationSession,
|
||||
request qmi.NASInitiateNetworkRegisterRequest,
|
||||
setAutomatic bool,
|
||||
target *qmi.ManualNetworkSelection,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if session == nil {
|
||||
return errors.New("QMI NAS registration session is unavailable")
|
||||
}
|
||||
if request.Mode == 0 {
|
||||
request = qmiRegistrationRequestAutomatic()
|
||||
}
|
||||
|
||||
mode, err := session.GetOperatingMode(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read QMI operating mode: %w", err)
|
||||
}
|
||||
if mode == qmi.ModeLowPower || mode == qmi.ModeOffline || mode == qmi.ModeShutdown || mode == qmi.ModeReset {
|
||||
if err := session.SetOperatingMode(ctx, qmi.ModeOnline); err != nil {
|
||||
return fmt.Errorf("restore QMI online mode: %w", err)
|
||||
}
|
||||
if err := waitNativeQMIRegistration(ctx); err != nil {
|
||||
return fmt.Errorf("wait for QMI online mode: %w", err)
|
||||
}
|
||||
mode, err = session.GetOperatingMode(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recheck QMI operating mode: %w", err)
|
||||
}
|
||||
if mode == qmi.ModeLowPower || mode == qmi.ModeOffline || mode == qmi.ModeShutdown || mode == qmi.ModeReset {
|
||||
return fmt.Errorf("QMI operating mode remained non-online after recovery: %d", mode)
|
||||
}
|
||||
}
|
||||
|
||||
if setAutomatic {
|
||||
if err := session.SetSystemSelectionPreference(ctx, qmiSelectionAutomaticPreference()); err != nil {
|
||||
// Some OpenStick firmware accepts the preference but reports an
|
||||
// unsupported result for an optional NAS TLV. The explicit NAS register
|
||||
// below remains the authoritative trigger.
|
||||
if !isUnsupportedQMISelectionCommand(err) {
|
||||
return fmt.Errorf("restore automatic QMI NAS selection: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
registerIssued := false
|
||||
forceSearchIssued := false
|
||||
radioCycleIssued := false
|
||||
forceSearchUnsupported := false
|
||||
manualTarget := target != nil && request.Mode == qmi.NASNetworkRegisterManual
|
||||
for attempt := 1; attempt <= nativeQMIRegistrationMaxAttempts; attempt++ {
|
||||
serving, servingErr := session.GetServingSystem(ctx)
|
||||
if servingErr != nil {
|
||||
if err := waitNativeQMIRegistration(ctx); err != nil {
|
||||
return fmt.Errorf("read QMI serving system: %w", servingErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if serving == nil {
|
||||
return errors.New("QMI serving system returned no data")
|
||||
}
|
||||
if qmiRegistrationStateRegistered(serving.RegistrationState) {
|
||||
if target == nil || qmiServingSystemMatchesTarget(serving, *target) {
|
||||
if serving.PSAttached {
|
||||
return nil
|
||||
}
|
||||
if err := session.AttachDetach(ctx, true); err != nil {
|
||||
return fmt.Errorf("attach QMI packet service: %w", err)
|
||||
}
|
||||
} else if !registerIssued {
|
||||
if manualTarget {
|
||||
var triggerErr error
|
||||
forceSearchIssued, forceSearchUnsupported, triggerErr = triggerNativeQMIManualRegistration(
|
||||
ctx, session, &request, serving,
|
||||
)
|
||||
if triggerErr != nil {
|
||||
return triggerErr
|
||||
}
|
||||
} else if err := session.InitiateNetworkRegister(ctx, request); err != nil {
|
||||
return fmt.Errorf("initiate QMI network registration: %w", err)
|
||||
}
|
||||
registerIssued = true
|
||||
}
|
||||
} else if serving.RegistrationState == qmi.RegStateDenied {
|
||||
return errors.New("QMI network registration was denied")
|
||||
} else if !registerIssued {
|
||||
if manualTarget {
|
||||
var triggerErr error
|
||||
forceSearchIssued, forceSearchUnsupported, triggerErr = triggerNativeQMIManualRegistration(
|
||||
ctx, session, &request, serving,
|
||||
)
|
||||
if triggerErr != nil {
|
||||
return triggerErr
|
||||
}
|
||||
} else if err := session.InitiateNetworkRegister(ctx, request); err != nil {
|
||||
if !(setAutomatic && isUnsupportedQMIRegistrationCommand(err, qmi.NASInitiateNetworkRegister)) {
|
||||
return fmt.Errorf("initiate QMI network registration: %w", err)
|
||||
}
|
||||
}
|
||||
registerIssued = true
|
||||
}
|
||||
|
||||
searching := serving.RegistrationState == qmi.RegStateSearching
|
||||
if target != nil && qmiRegistrationStateRegistered(serving.RegistrationState) && !qmiServingSystemMatchesTarget(serving, *target) {
|
||||
searching = true
|
||||
}
|
||||
if searching && registerIssued && !forceSearchIssued && !forceSearchUnsupported && attempt >= 2 {
|
||||
forceSearchIssued = true
|
||||
if err := session.ForceNetworkSearch(ctx); err != nil {
|
||||
if isUnsupportedQMIForceSearch(err) {
|
||||
forceSearchUnsupported = true
|
||||
} else {
|
||||
return fmt.Errorf("force QMI network search: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
radioCycleAfter := nativeQMIRegistrationRadioCycleThreshold(forceSearchUnsupported)
|
||||
if searching && registerIssued && !radioCycleIssued && attempt >= radioCycleAfter {
|
||||
radioCycleIssued = true
|
||||
if err := session.SetOperatingMode(ctx, qmi.ModeLowPower); err == nil {
|
||||
_ = waitNativeQMIRegistration(ctx)
|
||||
_ = session.SetOperatingMode(ctx, qmi.ModeOnline)
|
||||
registerIssued = false
|
||||
}
|
||||
}
|
||||
if err := waitNativeQMIRegistration(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("QMI network registration/PS attach timed out after %d attempts", nativeQMIRegistrationMaxAttempts)
|
||||
}
|
||||
|
||||
func qmiServingSystemMatchesTarget(serving *qmi.ServingSystem, target qmi.ManualNetworkSelection) bool {
|
||||
return serving != nil && serving.MCC == target.MCC && serving.MNC == target.MNC
|
||||
}
|
||||
|
||||
func waitNativeQMIRegistration(ctx context.Context) error {
|
||||
timer := time.NewTimer(nativeQMIRegistrationPollInterval)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) nativeQMIOperatorSelectionLocked(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
) (OperatorSelection, error) {
|
||||
session, err := manager.openNativeQMIRegistration(ctx, candidate)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("open QMI NAS operator selection: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
pref, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err)
|
||||
}
|
||||
return qmiOperatorSelectionFromPreference(pref)
|
||||
}
|
||||
|
||||
func (manager *Manager) setNativeQMIOperatorSelectionLocked(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
automatic bool,
|
||||
plmn string,
|
||||
accessTechnologyValue *int,
|
||||
) (OperatorSelection, error) {
|
||||
session, err := manager.openNativeQMIRegistration(ctx, candidate)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("open QMI NAS operator selection: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if automatic {
|
||||
request := qmiRegistrationRequestAutomatic()
|
||||
if err := ensureNativeQMIRegistration(ctx, session, request, true); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
pref, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err)
|
||||
}
|
||||
return qmiOperatorSelectionFromPreference(pref)
|
||||
}
|
||||
request, err := qmiManualRegisterRequest(plmn, accessTechnologyValue)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
preference, target, err := qmiManualSelectionPreferenceWithRAT(plmn, accessTechnologyValue)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
// InitiateNetworkRegister is only a one-shot trigger on this firmware. The
|
||||
// manual preference must be written separately or the next reconcile will
|
||||
// read automatic selection and undo the requested lock.
|
||||
if err := session.SetSystemSelectionPreference(ctx, preference); err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("set manual QMI network selection: %w", err)
|
||||
}
|
||||
if err := ensureNativeQMIRegistrationForTarget(ctx, session, request, false, &target); err != nil {
|
||||
manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
actual, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID)
|
||||
return OperatorSelection{}, fmt.Errorf("verify manual QMI network selection: %w", err)
|
||||
}
|
||||
if actual == nil || !actual.HasManualNetworkSelection || actual.ManualNetworkSelection != target {
|
||||
manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID)
|
||||
return OperatorSelection{}, fmt.Errorf("modem did not retain manual PLMN %s", strings.TrimSpace(plmn))
|
||||
}
|
||||
return qmiOperatorSelectionFromPreference(actual)
|
||||
}
|
||||
|
||||
// restoreNativeQMISelectionAfterFailure prevents a failed manual lock from
|
||||
// leaving the modem in a searching/manual state. The caller may already have
|
||||
// exhausted its request deadline, so rollback uses a fresh bounded context and
|
||||
// schedules the normal background reconcile as a second line of defence.
|
||||
func (manager *Manager) restoreNativeQMISelectionAfterFailure(
|
||||
session nativeQMIRegistrationSession,
|
||||
deviceID string,
|
||||
) {
|
||||
if manager == nil || session == nil {
|
||||
return
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
defer cancel()
|
||||
_ = session.SetSystemSelectionPreference(rollbackCtx, qmiSelectionAutomaticPreference())
|
||||
_ = session.InitiateNetworkRegister(rollbackCtx, qmiRegistrationRequestAutomatic())
|
||||
_ = session.ForceNetworkSearch(rollbackCtx)
|
||||
if strings.TrimSpace(deviceID) != "" {
|
||||
manager.startNativeQMIRegistrationReconcile(deviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) reRegisterNativeQMIOperatorLocked(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
) (OperatorSelection, error) {
|
||||
session, err := manager.openNativeQMIRegistration(ctx, candidate)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("open QMI NAS re-registration: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
pref, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err)
|
||||
}
|
||||
request := qmiRegistrationRequestAutomatic()
|
||||
setAutomatic := true
|
||||
selection := OperatorSelection{Mode: 0}
|
||||
if pref != nil && pref.HasManualNetworkSelection {
|
||||
setAutomatic = false
|
||||
request.Mode = qmi.NASNetworkRegisterManual
|
||||
request.MCC = pref.ManualNetworkSelection.MCC
|
||||
request.MNC = pref.ManualNetworkSelection.MNC
|
||||
request.IncludesPCSDigit = pref.ManualNetworkSelection.IncludesPCSDigit
|
||||
request.ChangeDuration = qmi.NASChangeDurationPermanent
|
||||
request.HasChangeDuration = true
|
||||
if pref.HasModePreference {
|
||||
request.RadioAccessTech = qmiRATFromModePreference(pref.ModePreference)
|
||||
}
|
||||
selection, err = qmiOperatorSelectionFromPreference(pref)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
}
|
||||
var target *qmi.ManualNetworkSelection
|
||||
if pref != nil && pref.HasManualNetworkSelection {
|
||||
target = &pref.ManualNetworkSelection
|
||||
}
|
||||
if err := ensureNativeQMIRegistrationForTarget(ctx, session, request, setAutomatic, target); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
type fakeNativeQMIRegistrationSession struct {
|
||||
mode qmi.OperatingMode
|
||||
serving []*qmi.ServingSystem
|
||||
selection *qmi.SystemSelectionPreference
|
||||
setModes []qmi.OperatingMode
|
||||
setPreferences []qmi.SystemSelectionPreference
|
||||
registerRequests []qmi.NASInitiateNetworkRegisterRequest
|
||||
forceSearches int
|
||||
forceSearchErr error
|
||||
registerErr error
|
||||
attachRequests []bool
|
||||
closeCount int
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) {
|
||||
return session.mode, nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) SetOperatingMode(_ context.Context, mode qmi.OperatingMode) error {
|
||||
session.mode = mode
|
||||
session.setModes = append(session.setModes, mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) Close() error {
|
||||
session.closeCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) GetServingSystem(context.Context) (*qmi.ServingSystem, error) {
|
||||
if len(session.serving) == 0 {
|
||||
return &qmi.ServingSystem{RegistrationState: qmi.RegStateSearching}, nil
|
||||
}
|
||||
current := session.serving[0]
|
||||
if len(session.serving) > 1 {
|
||||
session.serving = session.serving[1:]
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) GetSystemSelectionPreference(context.Context) (*qmi.SystemSelectionPreference, error) {
|
||||
if session.selection == nil {
|
||||
return &qmi.SystemSelectionPreference{}, nil
|
||||
}
|
||||
return session.selection, nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) SetSystemSelectionPreference(_ context.Context, pref qmi.SystemSelectionPreference) error {
|
||||
session.selection = &pref
|
||||
session.setPreferences = append(session.setPreferences, pref)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) InitiateNetworkRegister(_ context.Context, req qmi.NASInitiateNetworkRegisterRequest) error {
|
||||
session.registerRequests = append(session.registerRequests, req)
|
||||
return session.registerErr
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) ForceNetworkSearch(context.Context) error {
|
||||
session.forceSearches++
|
||||
return session.forceSearchErr
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) AttachDetach(_ context.Context, attached bool) error {
|
||||
session.attachRequests = append(session.attachRequests, attached)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEnsureNativeQMIRegistrationDrivesNASSequence(t *testing.T) {
|
||||
session := &fakeNativeQMIRegistrationSession{
|
||||
mode: qmi.ModeLowPower,
|
||||
serving: []*qmi.ServingSystem{
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: false},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true},
|
||||
},
|
||||
}
|
||||
|
||||
if err := ensureNativeQMIRegistration(context.Background(), session, qmiRegistrationRequestAutomatic(), true); err != nil {
|
||||
t.Fatalf("ensure native QMI registration: %v", err)
|
||||
}
|
||||
if len(session.setModes) != 1 || session.setModes[0] != qmi.ModeOnline {
|
||||
t.Fatalf("operating mode writes = %#v, want [online]", session.setModes)
|
||||
}
|
||||
if len(session.setPreferences) != 1 || !session.setPreferences[0].HasNetworkSelectionPreference ||
|
||||
session.setPreferences[0].NetworkSelectionPreference != qmi.NASNetworkSelectionAutomatic {
|
||||
t.Fatalf("selection writes = %#v, want automatic", session.setPreferences)
|
||||
}
|
||||
if len(session.registerRequests) != 1 || session.registerRequests[0].Mode != qmi.NASNetworkRegisterAutomatic {
|
||||
t.Fatalf("registration requests = %#v, want one automatic request", session.registerRequests)
|
||||
}
|
||||
if session.forceSearches != 1 {
|
||||
t.Fatalf("force-search count = %d, want 1", session.forceSearches)
|
||||
}
|
||||
if len(session.attachRequests) != 1 || !session.attachRequests[0] {
|
||||
t.Fatalf("attach requests = %#v, want one attach", session.attachRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualRegisterRequestMapsPLMNAndRAT(t *testing.T) {
|
||||
rat := 7
|
||||
request, err := qmiManualRegisterRequest("46001", &rat)
|
||||
if err != nil {
|
||||
t.Fatalf("manual request: %v", err)
|
||||
}
|
||||
if request.Mode != qmi.NASNetworkRegisterManual || request.MCC != 460 || request.MNC != 1 ||
|
||||
request.IncludesPCSDigit || request.RadioAccessTech != 0x08 || !request.HasChangeDuration ||
|
||||
request.ChangeDuration != qmi.NASChangeDurationPermanent {
|
||||
t.Fatalf("manual request = %#v", request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualSelectionPreferenceMapsPLMN(t *testing.T) {
|
||||
pref, selection, err := qmiManualSelectionPreference("46001")
|
||||
if err != nil {
|
||||
t.Fatalf("manual preference: %v", err)
|
||||
}
|
||||
if pref.NetworkSelectionPreference != qmi.NASNetworkSelectionManual ||
|
||||
!pref.HasNetworkSelectionPreference || !pref.HasManualNetworkSelection ||
|
||||
!pref.HasChangeDuration || pref.ChangeDuration != qmi.NASChangeDurationPermanent {
|
||||
t.Fatalf("manual preference = %#v", pref)
|
||||
}
|
||||
if selection.MCC != 460 || selection.MNC != 1 || selection.IncludesPCSDigit {
|
||||
t.Fatalf("manual selection = %#v", selection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualSelectionPreferenceMapsRAT(t *testing.T) {
|
||||
rat := 7
|
||||
pref, _, err := qmiManualSelectionPreferenceWithRAT("46001", &rat)
|
||||
if err != nil {
|
||||
t.Fatalf("manual preference: %v", err)
|
||||
}
|
||||
if !pref.HasModePreference || pref.ModePreference != qmi.NASRatModePreferenceLTE {
|
||||
t.Fatalf("manual preference mode = %#v, want LTE mode preference", pref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualRegisterRequestRejectsUnknownRAT(t *testing.T) {
|
||||
rat := 1
|
||||
if _, err := qmiManualRegisterRequest("46001", &rat); err == nil {
|
||||
t.Fatal("manual request with unknown RAT must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNativeQMIRegistrationWaitsForManualTarget(t *testing.T) {
|
||||
session := &fakeNativeQMIRegistrationSession{
|
||||
mode: qmi.ModeOnline,
|
||||
serving: []*qmi.ServingSystem{
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 0},
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: false, MCC: 460, MNC: 1},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 1},
|
||||
},
|
||||
}
|
||||
request, err := qmiManualRegisterRequest("46001", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("manual request: %v", err)
|
||||
}
|
||||
target := qmi.ManualNetworkSelection{MCC: 460, MNC: 1}
|
||||
if err := ensureNativeQMIRegistrationForTarget(context.Background(), session, request, false, &target); err != nil {
|
||||
t.Fatalf("ensure manual registration: %v", err)
|
||||
}
|
||||
if len(session.registerRequests) != 0 {
|
||||
t.Fatalf("registration requests = %#v, want force-search-only manual trigger", session.registerRequests)
|
||||
}
|
||||
if session.forceSearches != 1 {
|
||||
t.Fatalf("force-search count = %d, want 1", session.forceSearches)
|
||||
}
|
||||
if len(session.attachRequests) != 1 || !session.attachRequests[0] {
|
||||
t.Fatalf("attach requests = %#v, want one attach", session.attachRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNativeQMIRegistrationFallsBackWhenForceSearchUnsupported(t *testing.T) {
|
||||
session := &fakeNativeQMIRegistrationSession{
|
||||
serving: []*qmi.ServingSystem{
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, RadioInterface: 8, MCC: 460, MNC: 0},
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: false, MCC: 460, MNC: 1},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 1},
|
||||
},
|
||||
forceSearchErr: &qmi.QMIError{
|
||||
Service: qmi.ServiceNAS, MessageID: qmi.NASForceNetworkSearch,
|
||||
Result: 0x0001, ErrorCode: qmi.QMIErrNotSupported,
|
||||
},
|
||||
}
|
||||
request, err := qmiManualRegisterRequest("46001", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("manual request: %v", err)
|
||||
}
|
||||
target := qmi.ManualNetworkSelection{MCC: 460, MNC: 1}
|
||||
if err := ensureNativeQMIRegistrationForTarget(context.Background(), session, request, false, &target); err != nil {
|
||||
t.Fatalf("ensure manual registration: %v", err)
|
||||
}
|
||||
if len(session.registerRequests) != 1 || session.registerRequests[0].RadioAccessTech != 8 {
|
||||
t.Fatalf("registration requests = %#v, want one LTE fallback request", session.registerRequests)
|
||||
}
|
||||
if session.forceSearches != 1 {
|
||||
t.Fatalf("force-search count = %d, want one unsupported attempt", session.forceSearches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeQMIRegistrationCyclesEarlyWhenForceSearchUnsupported(t *testing.T) {
|
||||
if got := nativeQMIRegistrationRadioCycleThreshold(true); got != 3 {
|
||||
t.Fatalf("unsupported force-search threshold = %d, want 3", got)
|
||||
}
|
||||
if got := nativeQMIRegistrationRadioCycleThreshold(false); got != 30 {
|
||||
t.Fatalf("supported force-search threshold = %d, want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNativeQMICandidateRequiresOpenStickWWANPair(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidate modem.Candidate
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "native",
|
||||
candidate: modem.Candidate{
|
||||
ID: "wwan0",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "mhi native discovery id",
|
||||
candidate: modem.Candidate{
|
||||
ID: "mhi-wwan0",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different control device",
|
||||
candidate: modem.Candidate{
|
||||
ID: "wwan0",
|
||||
QMIControl: "/dev/cdc-wdm0",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non native id",
|
||||
candidate: modem.Candidate{
|
||||
ID: "usb0",
|
||||
QMIControl: "/dev/usb0qmi0",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isNativeQMICandidate(tt.candidate); got != tt.want {
|
||||
t.Fatalf("isNativeQMICandidate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,25 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"234": "GB",
|
||||
"262": "DE",
|
||||
"310": "US",
|
||||
"460": "CN",
|
||||
}
|
||||
for mcc, want := range tests {
|
||||
if got, ok := CountryForMCC(mcc); !ok || got != want {
|
||||
t.Errorf("CountryForMCC(%q) = (%q, %v), want %q", mcc, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"", "23", "999", "abcd"} {
|
||||
if got, ok := CountryForMCC(invalid); ok || got != "" {
|
||||
t.Errorf("CountryForMCC(%q) = (%q, %v), want unknown", invalid, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
@@ -64,6 +83,7 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
||||
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
|
||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
@@ -73,3 +93,20 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
||||
plmn, name, country, ok := CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "454006395879502", ICCID: "89852350126077295027",
|
||||
SPN: "Saily", GID1: "536E617065", GID2: "536E617065000012", MNCLength: 2,
|
||||
})
|
||||
if !ok || plmn != "45400" || name != "Webbing" || country != "HK" {
|
||||
t.Fatalf("CarrierForSIM exact rule = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
|
||||
plmn, name, country, ok = CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "454006395879502", SPN: "Saily", MNCLength: 2,
|
||||
})
|
||||
if !ok || plmn != "45400" || name != "1O1O / csl / Club Sim" || country != "HK" {
|
||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
+119
-1
@@ -21,6 +21,7 @@ func (manager *Manager) readSnapshot(
|
||||
candidate modem.Candidate,
|
||||
backend string,
|
||||
previousICCID string,
|
||||
previousSnapshot *Snapshot,
|
||||
client modem.Client,
|
||||
) (Snapshot, error) {
|
||||
snapshot := Snapshot{
|
||||
@@ -55,10 +56,23 @@ func (manager *Manager) readSnapshot(
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
||||
cancelQMI()
|
||||
if qmiErr == nil {
|
||||
snapshot.ICCID = qmiICCID
|
||||
ccidErr = nil
|
||||
} else {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID via QMI UIM: "+qmiErr.Error())
|
||||
}
|
||||
}
|
||||
if ccidErr != nil {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||
} else {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if snapshot.ICCID == "" {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
}
|
||||
}
|
||||
previousICCID = strings.TrimSpace(previousICCID)
|
||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||
@@ -81,6 +95,25 @@ func (manager *Manager) readSnapshot(
|
||||
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
|
||||
snapshot.SPN = parseSPN(response)
|
||||
}
|
||||
if previousSnapshot != nil && previousSnapshot.IdentityFilesRead &&
|
||||
strings.EqualFold(strings.TrimSpace(previousSnapshot.ICCID), strings.TrimSpace(snapshot.ICCID)) {
|
||||
snapshot.MNCLength = previousSnapshot.MNCLength
|
||||
snapshot.GID1 = previousSnapshot.GID1
|
||||
snapshot.GID2 = previousSnapshot.GID2
|
||||
snapshot.IdentityFilesRead = true
|
||||
} else {
|
||||
// Android's carrier resolver does not identify MVNOs from MCC/MNC alone.
|
||||
// Read these files once per inserted ICCID and cache even an empty result;
|
||||
// repeatedly probing unsupported EFs would add avoidable modem traffic.
|
||||
if efAD := manager.readTransparentSIMFile(ctx, client, 28589); len(efAD) >= 4 {
|
||||
if length := int(efAD[3] & 0x0f); length == 2 || length == 3 {
|
||||
snapshot.MNCLength = length
|
||||
}
|
||||
}
|
||||
snapshot.GID1 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28478))
|
||||
snapshot.GID2 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28479))
|
||||
snapshot.IdentityFilesRead = true
|
||||
}
|
||||
if response, ok := optional("AT+CSQ"); ok {
|
||||
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
||||
}
|
||||
@@ -164,6 +197,91 @@ func (manager *Manager) readSnapshot(
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) readTransparentSIMFile(ctx context.Context, client modem.Client, fileID int) []byte {
|
||||
response, err := manager.command(ctx, client, fmt.Sprintf("AT+CRSM=192,%d,0,0,0", fileID))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
size := transparentSIMFileSize(crsmPayload(response))
|
||||
if size <= 0 || size > 64 {
|
||||
return nil
|
||||
}
|
||||
response, err = manager.command(ctx, client, fmt.Sprintf("AT+CRSM=176,%d,0,0,%d", fileID, size))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return crsmPayload(response)
|
||||
}
|
||||
|
||||
func transparentSIMFileSize(payload []byte) int {
|
||||
// USIM FCP templates contain file size in tag 0x80. Skip the outer 0x62
|
||||
// template and walk its immediate TLVs.
|
||||
content := payload
|
||||
if len(content) >= 2 && content[0] == 0x62 {
|
||||
length, header, ok := berLength(content[1:])
|
||||
if !ok || 1+header+length > len(content) {
|
||||
return 0
|
||||
}
|
||||
content = content[1+header : 1+header+length]
|
||||
}
|
||||
for offset := 0; offset+2 <= len(content); {
|
||||
tag := content[offset]
|
||||
length, header, ok := berLength(content[offset+1:])
|
||||
start := offset + 1 + header
|
||||
end := start + length
|
||||
if !ok || end > len(content) {
|
||||
break
|
||||
}
|
||||
if tag == 0x80 && (length == 1 || length == 2) {
|
||||
size := 0
|
||||
for _, value := range content[start:end] {
|
||||
size = size<<8 | int(value)
|
||||
}
|
||||
return size
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
// Legacy GSM GET RESPONSE data stores file size in bytes 2 and 3.
|
||||
if len(payload) >= 4 && payload[0] != 0x62 {
|
||||
return int(payload[2])<<8 | int(payload[3])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func berLength(value []byte) (length, header int, ok bool) {
|
||||
if len(value) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if value[0] < 0x80 {
|
||||
return int(value[0]), 1, true
|
||||
}
|
||||
count := int(value[0] & 0x7f)
|
||||
if count == 0 || count > 2 || len(value) < count+1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, item := range value[1 : count+1] {
|
||||
length = length<<8 | int(item)
|
||||
}
|
||||
return length, count + 1, true
|
||||
}
|
||||
|
||||
func encodeSIMGroupID(value []byte) string {
|
||||
if len(value) == 0 {
|
||||
return ""
|
||||
}
|
||||
allPadding := true
|
||||
for _, item := range value {
|
||||
if item != 0xff {
|
||||
allPadding = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allPadding {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(value))
|
||||
}
|
||||
|
||||
func parseSPN(response modem.Response) string {
|
||||
value := valueAfterPrefix(response, "+CRSM:")
|
||||
fields := csvValues(value)
|
||||
|
||||
@@ -105,6 +105,10 @@ type Snapshot struct {
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
SPN string `json:"spn,omitempty"`
|
||||
MNCLength int `json:"mncLength,omitempty"`
|
||||
GID1 string `json:"gid1,omitempty"`
|
||||
GID2 string `json:"gid2,omitempty"`
|
||||
IdentityFilesRead bool `json:"-"`
|
||||
OperatingMode int `json:"operatingMode"`
|
||||
ModeKnown bool `json:"modeKnown"`
|
||||
FlightMode bool `json:"flightMode"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
@@ -21,6 +22,22 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error)
|
||||
if path == "" {
|
||||
return nil, errors.New("modem: candidate has no AT port")
|
||||
}
|
||||
if isNativeWWANATPath(path) {
|
||||
rawPort, err := openNativeWWANATTransport(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open WWAN AT port %s: %w", path, err)
|
||||
}
|
||||
if err := rawPort.ResetInputBuffer(); err != nil {
|
||||
_ = rawPort.Close()
|
||||
return nil, fmt.Errorf("reset WWAN AT input buffer %s: %w", path, err)
|
||||
}
|
||||
session, err := NewSession(rawPort, opener.SessionOptions)
|
||||
if err != nil {
|
||||
_ = rawPort.Close()
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
baudRate := opener.BaudRate
|
||||
if baudRate <= 0 {
|
||||
baudRate = 115200
|
||||
@@ -45,3 +62,8 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func isNativeWWANATPath(path string) bool {
|
||||
_, kind, _, ok := parseWWANPortName(filepath.Base(filepath.Clean(path)))
|
||||
return ok && kind == "at"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package modem
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNativeWWANATPath(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/dev/wwan0at0",
|
||||
"/dev/wwan12at3",
|
||||
} {
|
||||
if !isNativeWWANATPath(path) {
|
||||
t.Errorf("isNativeWWANATPath(%q) = false", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/dev/wwan0qmi0",
|
||||
"/dev/ttyUSB2",
|
||||
"/tmp/wwan-at",
|
||||
"/dev/wwanat0",
|
||||
} {
|
||||
if isNativeWWANATPath(path) {
|
||||
t.Errorf("isNativeWWANATPath(%q) = true", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build linux
|
||||
|
||||
package modem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// nativeWWANATTransport adapts a Linux WWAN AT character device to Session's
|
||||
// serial-like transport contract. WWAN ports are not TTYs, so termios ioctls
|
||||
// used by ordinary serial libraries fail even though raw AT read/write works.
|
||||
type nativeWWANATTransport struct {
|
||||
mu sync.RWMutex
|
||||
fd int
|
||||
readTimeout time.Duration
|
||||
closed bool
|
||||
}
|
||||
|
||||
func openNativeWWANATTransport(path string) (Transport, error) {
|
||||
fd, err := unix.Open(path, unix.O_RDWR|unix.O_NONBLOCK|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &nativeWWANATTransport{fd: fd, readTimeout: -1}, nil
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Read(buffer []byte) (int, error) {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
deadline := time.Time{}
|
||||
if transport.readTimeout >= 0 {
|
||||
deadline = time.Now().Add(transport.readTimeout)
|
||||
}
|
||||
for {
|
||||
timeout := -1
|
||||
if !deadline.IsZero() {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
timeout = int((remaining + time.Millisecond - 1) / time.Millisecond)
|
||||
}
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, timeout)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ready == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if fds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 &&
|
||||
fds[0].Revents&unix.POLLIN == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
count, err := unix.Read(transport.fd, buffer)
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Write(buffer []byte) (int, error) {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
for {
|
||||
count, err := unix.Write(transport.fd, buffer)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, unix.EAGAIN) {
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLOUT}}
|
||||
if _, pollErr := unix.Poll(fds, 1000); pollErr != nil {
|
||||
return 0, pollErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Drain() error {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
// WWAN character-device writes are handed to the modem synchronously and
|
||||
// have no termios output queue to drain.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := unix.Read(transport.fd, buffer); err != nil {
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) SetReadTimeout(timeout time.Duration) error {
|
||||
if timeout < -1 {
|
||||
return fmt.Errorf("invalid read timeout %s", timeout)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
if transport.closed {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
transport.readTimeout = timeout
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Close() error {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
if transport.closed {
|
||||
return nil
|
||||
}
|
||||
transport.closed = true
|
||||
return unix.Close(transport.fd)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package modem
|
||||
|
||||
import "fmt"
|
||||
|
||||
func openNativeWWANATTransport(path string) (Transport, error) {
|
||||
return nil, fmt.Errorf("native WWAN AT ports are unsupported on this platform: %s", path)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package qmiport coordinates access to native Linux WWAN QMI control ports.
|
||||
package qmiport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type portHandle interface {
|
||||
Close() error
|
||||
Stat() (os.FileInfo, error)
|
||||
}
|
||||
|
||||
type portOpener func(string) (portHandle, error)
|
||||
|
||||
type entry struct {
|
||||
gate chan struct{}
|
||||
|
||||
keeperMu sync.Mutex
|
||||
keeper portHandle
|
||||
}
|
||||
|
||||
type coordinator struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*entry
|
||||
opener portOpener
|
||||
}
|
||||
|
||||
// Lease serializes one QMI transaction sequence for a control port. Release
|
||||
// does not close the keepalive descriptor: the old OpenStick 410 WWAN driver
|
||||
// removes DATA5_CNTL when the final descriptor closes, and does not reliably
|
||||
// recreate it until the modem is reset.
|
||||
type Lease struct {
|
||||
entry *entry
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
var processCoordinator = newCoordinator(openPort)
|
||||
|
||||
func newCoordinator(opener portOpener) *coordinator {
|
||||
return &coordinator{
|
||||
entries: make(map[string]*entry),
|
||||
opener: opener,
|
||||
}
|
||||
}
|
||||
|
||||
func openPort(path string) (portHandle, error) {
|
||||
return os.OpenFile(path, os.O_RDWR|syscall.O_NONBLOCK|syscall.O_NOCTTY, 0)
|
||||
}
|
||||
|
||||
// Acquire keeps path open for the process lifetime and grants exclusive QMI
|
||||
// access until the returned lease is released. A modem reset replaces the
|
||||
// device node; ensureKeeper detects that inode change and rearms the keepalive.
|
||||
func Acquire(ctx context.Context, path string) (*Lease, error) {
|
||||
return processCoordinator.acquire(ctx, path)
|
||||
}
|
||||
|
||||
func (coordinator *coordinator) acquire(ctx context.Context, path string) (*Lease, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path == "." || path == "" {
|
||||
return nil, errors.New("QMI control path is required")
|
||||
}
|
||||
coordinator.mu.Lock()
|
||||
item := coordinator.entries[path]
|
||||
if item == nil {
|
||||
item = &entry{gate: make(chan struct{}, 1)}
|
||||
item.gate <- struct{}{}
|
||||
coordinator.entries[path] = item
|
||||
}
|
||||
coordinator.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-item.gate:
|
||||
}
|
||||
if err := coordinator.ensureKeeper(path, item); err != nil {
|
||||
item.gate <- struct{}{}
|
||||
return nil, fmt.Errorf("keep QMI control port %s open: %w", path, err)
|
||||
}
|
||||
return &Lease{entry: item}, nil
|
||||
}
|
||||
|
||||
func (coordinator *coordinator) ensureKeeper(path string, item *entry) error {
|
||||
item.keeperMu.Lock()
|
||||
defer item.keeperMu.Unlock()
|
||||
|
||||
currentInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.keeper != nil {
|
||||
keeperInfo, statErr := item.keeper.Stat()
|
||||
if statErr == nil && os.SameFile(currentInfo, keeperInfo) {
|
||||
return nil
|
||||
}
|
||||
_ = item.keeper.Close()
|
||||
item.keeper = nil
|
||||
}
|
||||
keeper, err := coordinator.opener(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.keeper = keeper
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release allows the next QMI-UIM operation to use this control port.
|
||||
func (lease *Lease) Release() {
|
||||
if lease == nil || lease.entry == nil {
|
||||
return
|
||||
}
|
||||
lease.once.Do(func() {
|
||||
lease.entry.gate <- struct{}{}
|
||||
})
|
||||
}
|
||||
|
||||
func (coordinator *coordinator) close() error {
|
||||
coordinator.mu.Lock()
|
||||
entries := make([]*entry, 0, len(coordinator.entries))
|
||||
for _, item := range coordinator.entries {
|
||||
entries = append(entries, item)
|
||||
}
|
||||
coordinator.entries = make(map[string]*entry)
|
||||
coordinator.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
for _, item := range entries {
|
||||
item.keeperMu.Lock()
|
||||
if item.keeper != nil {
|
||||
errs = append(errs, item.keeper.Close())
|
||||
item.keeper = nil
|
||||
}
|
||||
item.keeperMu.Unlock()
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package qmiport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLeaseKeepsPortOpenAndSerializesUsers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "wwan0qmi0")
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var opens atomic.Int32
|
||||
coordinator := newCoordinator(func(path string) (portHandle, error) {
|
||||
opens.Add(1)
|
||||
return os.OpenFile(path, os.O_RDWR, 0)
|
||||
})
|
||||
t.Cleanup(func() { _ = coordinator.close() })
|
||||
|
||||
first, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquire: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := coordinator.acquire(ctx, path); err == nil {
|
||||
t.Fatal("second acquire succeeded before the first lease was released")
|
||||
}
|
||||
first.Release()
|
||||
|
||||
second, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("second acquire: %v", err)
|
||||
}
|
||||
second.Release()
|
||||
if got := opens.Load(); got != 1 {
|
||||
t.Fatalf("keepalive opens = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseReopensReplacedDeviceNode(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "wwan0qmi0")
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var opens atomic.Int32
|
||||
coordinator := newCoordinator(func(path string) (portHandle, error) {
|
||||
opens.Add(1)
|
||||
return os.OpenFile(path, os.O_RDWR, 0)
|
||||
})
|
||||
t.Cleanup(func() { _ = coordinator.close() })
|
||||
|
||||
first, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.Release()
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second.Release()
|
||||
if got := opens.Load(); got != 2 {
|
||||
t.Fatalf("keepalive opens = %d, want 2 after node replacement", got)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type automaticTaskNotification struct {
|
||||
}
|
||||
|
||||
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
|
||||
ctx = s.notificationDestinationContext(ctx)
|
||||
deviceLabel := task.DeviceID
|
||||
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
|
||||
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
|
||||
|
||||
@@ -344,7 +344,14 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": []any{}}})
|
||||
return true
|
||||
}
|
||||
devices := s.devices.List()
|
||||
// This endpoint backs the add-device dialog. Always perform a new physical
|
||||
// scan instead of serving Manager.List(), which intentionally retains
|
||||
// unplugged configured devices so the main device list can show them offline.
|
||||
devices, err := s.devices.Discover(r.Context())
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
configured, err := s.store.ListDevices(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
@@ -352,6 +359,9 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
result := make([]map[string]any, 0, len(devices))
|
||||
for _, entry := range devices {
|
||||
if !entry.Discovered {
|
||||
continue
|
||||
}
|
||||
candidate := entry.Candidate
|
||||
atPorts := make([]string, 0, len(candidate.Ports))
|
||||
for _, port := range candidate.Ports {
|
||||
@@ -1472,7 +1482,13 @@ func physicalMatchesConfig(entry device.Device, config store.Device) bool {
|
||||
return config.ModemIMEI == entry.Snapshot.IMEI
|
||||
}
|
||||
if config.USBPath != "" && candidate.USBPath != "" {
|
||||
return config.USBPath == candidate.USBPath
|
||||
if config.USBPath == candidate.USBPath {
|
||||
return true
|
||||
}
|
||||
// Sysfs paths may be stored through /sys/class symlinks while a
|
||||
// subsequent discovery returns the resolved device path. Keep checking
|
||||
// the selected AT/QMI nodes instead of rejecting a modem whose physical
|
||||
// path spelling changed but whose control plane is unchanged.
|
||||
}
|
||||
// Control and serial device nodes are allocation-order dependent. They are
|
||||
// only legacy fallbacks when no physical USB path or readable IMEI exists.
|
||||
@@ -1904,66 +1920,76 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||
if snapshot == nil {
|
||||
return map[string]any{
|
||||
"operator": "",
|
||||
"native_mcc": "",
|
||||
"native_mnc": "",
|
||||
"native_spn": "",
|
||||
"operator_country_code": "",
|
||||
"card_mcc": "",
|
||||
"card_mnc": "",
|
||||
"card_country": "",
|
||||
"service_blocked": false,
|
||||
"blocked_reason": "",
|
||||
"network_mode": "",
|
||||
"radio_band": "",
|
||||
"radio_channel": 0,
|
||||
"signal_dbm": 0,
|
||||
"signal_sinr": 0,
|
||||
"imei": "",
|
||||
"iccid": "",
|
||||
"reg_status": 0,
|
||||
"reg_status_text": "not refreshed",
|
||||
"sim_inserted": false,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"model": "",
|
||||
"operator": "",
|
||||
"native_mcc": "",
|
||||
"native_mnc": "",
|
||||
"native_spn": "",
|
||||
"operator_country_code": "",
|
||||
"card_mcc": "",
|
||||
"card_mnc": "",
|
||||
"card_country": "",
|
||||
"home_carrier_name": "",
|
||||
"home_carrier_plmn": "",
|
||||
"home_carrier_country_code": "",
|
||||
"service_blocked": false,
|
||||
"blocked_reason": "",
|
||||
"network_mode": "",
|
||||
"radio_band": "",
|
||||
"radio_channel": 0,
|
||||
"signal_dbm": 0,
|
||||
"signal_sinr": 0,
|
||||
"imei": "",
|
||||
"iccid": "",
|
||||
"reg_status": 0,
|
||||
"reg_status_text": "not refreshed",
|
||||
"sim_inserted": false,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"model": "",
|
||||
}
|
||||
}
|
||||
mcc, mnc := splitPLMN(snapshot.OperatorCode)
|
||||
_, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode)
|
||||
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
|
||||
cardMCC, cardMNC := device.CardMCCMNCWithLength(snapshot.IMSI, snapshot.MNCLength)
|
||||
homePLMN, homeCarrier, homeCountry, _ := device.CarrierForSIM(device.CarrierIdentity{
|
||||
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
|
||||
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
|
||||
})
|
||||
blockedReason := device.RegionBlockReason(snapshot.IMSI)
|
||||
return map[string]any{
|
||||
"operator": snapshot.OperatorName,
|
||||
"native_mcc": mcc,
|
||||
"native_mnc": mnc,
|
||||
"native_spn": snapshot.SPN,
|
||||
"operator_country_code": operatorCountryCode,
|
||||
"card_mcc": cardMCC,
|
||||
"card_mnc": cardMNC,
|
||||
"card_country": countryNameForMCC(cardMCC),
|
||||
"service_blocked": blockedReason != "",
|
||||
"blocked_reason": blockedReason,
|
||||
"network_mode": snapshot.AccessTech,
|
||||
"network_duplex": "",
|
||||
"radio_band": snapshot.Band,
|
||||
"radio_channel": parseDecimal(snapshot.Channel),
|
||||
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
||||
"signal_rsrp": pointerInt(snapshot.RSRP),
|
||||
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
||||
"signal_sinr": pointerInt(snapshot.SINR),
|
||||
"imei": snapshot.IMEI,
|
||||
"iccid": snapshot.ICCID,
|
||||
"imsi": snapshot.IMSI,
|
||||
"firmware": snapshot.Firmware,
|
||||
"model": snapshot.Model,
|
||||
"reg_status": snapshot.RegistrationStatus,
|
||||
"reg_status_text": registrationText(snapshot),
|
||||
"ps_attached": snapshot.PSAttached,
|
||||
"sim_inserted": snapshotHasSIM(snapshot),
|
||||
"operating_mode": snapshot.OperatingMode,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"operator": snapshot.OperatorName,
|
||||
"native_mcc": mcc,
|
||||
"native_mnc": mnc,
|
||||
"native_spn": snapshot.SPN,
|
||||
"operator_country_code": operatorCountryCode,
|
||||
"card_mcc": cardMCC,
|
||||
"card_mnc": cardMNC,
|
||||
"card_country": countryNameForMCC(cardMCC),
|
||||
"home_carrier_name": homeCarrier,
|
||||
"home_carrier_plmn": homePLMN,
|
||||
"home_carrier_country_code": homeCountry,
|
||||
"service_blocked": blockedReason != "",
|
||||
"blocked_reason": blockedReason,
|
||||
"network_mode": snapshot.AccessTech,
|
||||
"network_duplex": "",
|
||||
"radio_band": snapshot.Band,
|
||||
"radio_channel": parseDecimal(snapshot.Channel),
|
||||
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
||||
"signal_rsrp": pointerInt(snapshot.RSRP),
|
||||
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
||||
"signal_sinr": pointerInt(snapshot.SINR),
|
||||
"imei": snapshot.IMEI,
|
||||
"iccid": snapshot.ICCID,
|
||||
"imsi": snapshot.IMSI,
|
||||
"firmware": snapshot.Firmware,
|
||||
"model": snapshot.Model,
|
||||
"reg_status": snapshot.RegistrationStatus,
|
||||
"reg_status_text": registrationText(snapshot),
|
||||
"ps_attached": snapshot.PSAttached,
|
||||
"sim_inserted": snapshotHasSIM(snapshot),
|
||||
"operating_mode": snapshot.OperatingMode,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
type discoverySnapshotController struct {
|
||||
fakeDeviceController
|
||||
entries []device.Device
|
||||
discoverCalls int
|
||||
}
|
||||
|
||||
func (controller *discoverySnapshotController) Discover(context.Context) ([]device.Device, error) {
|
||||
controller.discoverCalls++
|
||||
return append([]device.Device(nil), controller.entries...), nil
|
||||
}
|
||||
|
||||
func TestDiscoveredDevicesPerformsFreshScanAndOmitsAbsentEntries(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
controller := &discoverySnapshotController{
|
||||
fakeDeviceController: fakeDeviceController{entry: device.Device{
|
||||
ID: "stale-device", Discovered: false,
|
||||
Candidate: modem.Candidate{ID: "stale-device", USBPath: "1-1"},
|
||||
}},
|
||||
entries: []device.Device{
|
||||
{
|
||||
ID: "current-device", Discovered: true,
|
||||
Candidate: modem.Candidate{ID: "current-device", USBPath: "2-1"},
|
||||
},
|
||||
{
|
||||
ID: "absent-device", Discovered: false,
|
||||
Candidate: modem.Candidate{ID: "absent-device", USBPath: "3-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
server := &Server{
|
||||
store: database, logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096, devices: controller,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/devices/discovered", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
if !server.handleDiscoveredDevices(recorder, request) {
|
||||
t.Fatal("handleDiscoveredDevices returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if controller.discoverCalls != 1 {
|
||||
t.Fatalf("Discover calls = %d, want 1", controller.discoverCalls)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, "current-device") {
|
||||
t.Fatalf("response omits current device: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "stale-device") || strings.Contains(body, "absent-device") {
|
||||
t.Fatalf("response contains an absent device: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,27 @@ func TestPhysicalMatchesConfigRejectsDuplicateAndroidSerialAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalMatchesConfigFallsBackWhenWWANSysfsPathWasResolved(t *testing.T) {
|
||||
config := store.Device{
|
||||
ID: "wwan0",
|
||||
USBPath: "/sys/class/wwan/wwan0",
|
||||
ATPort: "/dev/wwan0at0",
|
||||
ControlDevice: "/dev/wwan0qmi0",
|
||||
}
|
||||
entry := device.Device{
|
||||
ID: "mhi-wwan0",
|
||||
Candidate: modem.Candidate{
|
||||
USBPath: "/sys/devices/platform/soc/4080000.remoteproc/wwan/wwan0",
|
||||
ATPort: modem.Port{Path: "/dev/wwan0at0"},
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
HardwareKind: "wwan",
|
||||
},
|
||||
}
|
||||
if !physicalMatchesConfig(entry, config) {
|
||||
t.Fatal("resolved WWAN sysfs path should fall back to matching control nodes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDiscoveredDevicePrefersPhysicalIdentityOverSerialAlias(t *testing.T) {
|
||||
alias := "/dev/serial/by-id/usb-Android_Android-if02-port0"
|
||||
devices := []device.Device{
|
||||
|
||||
@@ -506,7 +506,7 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
|
||||
case strings.Contains(err.Error(), "between 12 and 1024"):
|
||||
case errors.Is(err, auth.ErrEmptyPassword):
|
||||
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
|
||||
case strings.Contains(err.Error(), "must differ"):
|
||||
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
|
||||
|
||||
+108
-32
@@ -29,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errUnsafeDestination = errors.New("notification destination is not public")
|
||||
errUnsafeDestination = errors.New("notification destination is not allowed")
|
||||
errProviderRejected = errors.New("notification provider rejected the test")
|
||||
telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
|
||||
)
|
||||
@@ -429,17 +429,18 @@ func (s *Server) handleNotificationTest(
|
||||
return
|
||||
}
|
||||
|
||||
notificationContext := s.notificationDestinationContext(r.Context())
|
||||
switch channel {
|
||||
case "webhook":
|
||||
err = sendWebhookNotificationTest(r.Context(), resolved)
|
||||
err = sendWebhookNotificationTest(notificationContext, resolved)
|
||||
case "telegram":
|
||||
err = sendTelegramNotificationTest(r.Context(), resolved)
|
||||
err = sendTelegramNotificationTest(notificationContext, resolved)
|
||||
case "email":
|
||||
err = sendEmailNotificationTest(r.Context(), resolved)
|
||||
err = sendEmailNotificationTest(notificationContext, resolved)
|
||||
case "bark":
|
||||
err = sendBarkNotificationTest(r.Context(), resolved)
|
||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -458,7 +459,7 @@ func (s *Server) handleNotificationTest(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"unsafe_destination",
|
||||
"notification destination must resolve only to public network addresses",
|
||||
"notification destination resolved to an unusable or protected system address",
|
||||
)
|
||||
case errors.Is(err, errProviderRejected):
|
||||
writeError(
|
||||
@@ -904,11 +905,12 @@ func restrictedHTTPClient(
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(proxy) != "" {
|
||||
parsed, err := validateOutboundURL(ctx, proxy, false)
|
||||
parsed, err := validateNotificationProxyURL(ctx, proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate notification proxy: %w", err)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
transport.DialContext = notificationProxyDialer(timeout)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
@@ -951,6 +953,17 @@ func validateOutboundURL(
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolveNotificationProxyAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||
@@ -984,17 +997,42 @@ func restrictedDialer(timeout time.Duration) func(
|
||||
}
|
||||
}
|
||||
|
||||
func notificationProxyDialer(timeout time.Duration) func(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (net.Conn, error) {
|
||||
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
return dialNotification(ctx, network, address, timeout, true)
|
||||
}
|
||||
}
|
||||
|
||||
func dialRestricted(
|
||||
ctx context.Context,
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
) (net.Conn, error) {
|
||||
return dialNotification(ctx, network, address, timeout, false)
|
||||
}
|
||||
|
||||
func dialNotification(
|
||||
ctx context.Context,
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
allowLocal bool,
|
||||
) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse outbound address: %w", err)
|
||||
}
|
||||
addresses, err := resolvePublicAddresses(ctx, host)
|
||||
var addresses []netip.Addr
|
||||
if allowLocal {
|
||||
addresses, err = resolveNotificationProxyAddresses(ctx, host)
|
||||
} else {
|
||||
addresses, err = resolvePublicAddresses(ctx, host)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1070,21 +1108,68 @@ func dialRestricted(
|
||||
if len(failures) == 0 {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
|
||||
return nil, fmt.Errorf("dial notification destination: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
// Notification delivery is outbound administrator-configured traffic. It
|
||||
// must not inherit the inbound Web access policy: DNS Fake-IP ranges, LAN
|
||||
// gateways, and local proxies are valid notification paths.
|
||||
return ctx
|
||||
}
|
||||
|
||||
func notificationAddressAllowed(_ context.Context, address netip.Addr) bool {
|
||||
address = address.Unmap()
|
||||
if !notificationTransportAddress(address) {
|
||||
return false
|
||||
}
|
||||
for _, fakeIP := range notificationFakeIPNetworks {
|
||||
if fakeIP.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, blocked := range blockedNotificationDestinationNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func notificationProxyAddressAllowed(address netip.Addr) bool {
|
||||
return notificationTransportAddress(address.Unmap())
|
||||
}
|
||||
|
||||
func notificationTransportAddress(address netip.Addr) bool {
|
||||
return address.IsValid() && !address.IsUnspecified() && !address.IsMulticast() &&
|
||||
!address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() &&
|
||||
address != netip.MustParseAddr("255.255.255.255") &&
|
||||
address != netip.MustParseAddr("100.100.100.200")
|
||||
}
|
||||
|
||||
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveNotificationAddresses(ctx, host, false)
|
||||
}
|
||||
|
||||
func resolveNotificationProxyAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveNotificationAddresses(ctx, host, true)
|
||||
}
|
||||
|
||||
func resolveNotificationAddresses(ctx context.Context, host string, allowLocal bool) ([]netip.Addr, error) {
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
if normalized == "" || normalized == "localhost" ||
|
||||
strings.HasSuffix(normalized, ".localhost") ||
|
||||
normalized == "metadata" ||
|
||||
strings.HasSuffix(normalized, ".internal") ||
|
||||
strings.HasSuffix(normalized, ".local") {
|
||||
if normalized == "" {
|
||||
return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
|
||||
}
|
||||
if literal, err := netip.ParseAddr(normalized); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if !publicNotificationAddress(literal) {
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, literal)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(literal)) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
@@ -1099,7 +1184,8 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !publicNotificationAddress(address) {
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, address)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(address)) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
||||
}
|
||||
result = append(result, address)
|
||||
@@ -1107,7 +1193,11 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var blockedNotificationNetworks = []netip.Prefix{
|
||||
var notificationFakeIPNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
}
|
||||
|
||||
var blockedNotificationDestinationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
@@ -1118,7 +1208,6 @@ var blockedNotificationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
@@ -1133,19 +1222,6 @@ var blockedNotificationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
|
||||
func publicNotificationAddress(address netip.Addr) bool {
|
||||
if !address.IsValid() || !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
for _, blocked := range blockedNotificationNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func configString(config map[string]any, key string) string {
|
||||
value, _ := config[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
|
||||
@@ -369,6 +369,10 @@ func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) {
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
if response["error"].(map[string]any)["code"] != "unsafe_destination" {
|
||||
t.Fatalf("Telegram metadata response = %#v", response)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
@@ -762,26 +766,28 @@ func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||
func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testing.T) {
|
||||
blocked := []string{
|
||||
"0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
|
||||
"169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1",
|
||||
"::1", "fc00::1", "fe80::1", "2001:db8::1",
|
||||
"169.254.169.254", "172.16.0.1", "192.168.1.1", "224.0.0.1",
|
||||
"255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::1",
|
||||
}
|
||||
for _, text := range blocked {
|
||||
address := netip.MustParseAddr(text)
|
||||
if publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly accepted as public", text)
|
||||
if notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly accepted for notification transport", text)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
|
||||
for _, text := range []string{
|
||||
"1.1.1.1", "198.18.0.1", "2606:4700:4700::1111",
|
||||
} {
|
||||
address := netip.MustParseAddr(text)
|
||||
if !publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly blocked", text)
|
||||
if !notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly blocked for notification transport", text)
|
||||
}
|
||||
}
|
||||
if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
|
||||
t.Fatal("localhost was not blocked")
|
||||
t.Fatal("local notification destination was not blocked")
|
||||
}
|
||||
if _, err := resolvePublicAddresses(
|
||||
context.Background(),
|
||||
@@ -789,6 +795,53 @@ func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||
); err == nil {
|
||||
t.Fatal("metadata IP was not blocked")
|
||||
}
|
||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
||||
notificationContext := server.notificationDestinationContext(context.Background())
|
||||
if addresses, err := resolvePublicAddresses(notificationContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
||||
t.Fatalf("Fake-IP notification destination = %v, %v", addresses, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProxyAcceptsLocalAddressWithoutWebAccessAllowlist(t *testing.T) {
|
||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
||||
ctx := server.notificationDestinationContext(context.Background())
|
||||
for _, host := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "198.18.0.1", "::1"} {
|
||||
if addresses, err := resolveNotificationProxyAddresses(ctx, host); err != nil || len(addresses) != 1 {
|
||||
t.Errorf("local notification proxy %s = %v, %v", host, addresses, err)
|
||||
}
|
||||
}
|
||||
if _, err := resolveNotificationProxyAddresses(ctx, "169.254.169.254"); err == nil {
|
||||
t.Fatal("cloud metadata address was accepted as a notification proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientConnectsThroughLocalProxy(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
hits.Add(1)
|
||||
if request.URL.Host != "1.1.1.1" {
|
||||
t.Errorf("proxy request host = %q", request.URL.Host)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
client, err := restrictedHTTPClient(context.Background(), 2*time.Second, proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent || hits.Load() != 1 {
|
||||
t.Fatalf("local proxy status = %d, hits = %d", response.StatusCode, hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string)
|
||||
} else {
|
||||
for _, message := range messages {
|
||||
notification := s.newSMSNotification(ctx, message)
|
||||
if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil {
|
||||
if sendErr := sendSMSNotification(s.notificationDestinationContext(ctx), channel, config, notification); sendErr != nil {
|
||||
if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
|
||||
s.logSMSNotificationError(channel, sendErr)
|
||||
lastError, lastErrorAt = sendErr.Error(), time.Now()
|
||||
|
||||
@@ -850,7 +850,7 @@ func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRun
|
||||
"ICCID:"+firstNonEmpty(snapshot.ICCID, "--"),
|
||||
"IMSI:"+firstNonEmpty(snapshot.IMSI, "--"),
|
||||
"号码:"+resolveTelegramPhoneNumber(associationNumber, wfcState, snapshot),
|
||||
"原运营商:"+telegramHomeCarrier(snapshot.IMSI, snapshot.SPN),
|
||||
"原运营商:"+telegramSnapshotHomeCarrier(snapshot),
|
||||
"当前网络:"+telegramCurrentNetwork(snapshot),
|
||||
"蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode],
|
||||
)
|
||||
@@ -929,20 +929,30 @@ func usableTelegramPhoneNumber(value string) bool {
|
||||
}
|
||||
|
||||
func telegramHomeCarrier(imsi string, spn ...string) string {
|
||||
plmn, name, country, ok := device.CarrierForIMSI(imsi)
|
||||
if !ok {
|
||||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||||
return strings.TrimSpace(spn[0])
|
||||
}
|
||||
identity := device.CarrierIdentity{IMSI: imsi}
|
||||
if len(spn) > 0 {
|
||||
identity.SPN = spn[0]
|
||||
}
|
||||
return telegramResolvedHomeCarrier(identity)
|
||||
}
|
||||
|
||||
func telegramSnapshotHomeCarrier(snapshot *device.Snapshot) string {
|
||||
if snapshot == nil {
|
||||
return "--"
|
||||
}
|
||||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||||
brand := strings.TrimSpace(spn[0])
|
||||
brandCountry := country
|
||||
if strings.Contains(strings.ToLower(brand), "lebara") && strings.HasPrefix(strings.TrimSpace(imsi), "20404") {
|
||||
brandCountry = "GB"
|
||||
return telegramResolvedHomeCarrier(device.CarrierIdentity{
|
||||
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
|
||||
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
|
||||
})
|
||||
}
|
||||
|
||||
func telegramResolvedHomeCarrier(identity device.CarrierIdentity) string {
|
||||
plmn, name, country, ok := device.CarrierForSIM(identity)
|
||||
if !ok {
|
||||
if strings.TrimSpace(identity.SPN) != "" {
|
||||
return strings.TrimSpace(identity.SPN)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(brandCountry), brand, "(认证核心 " + plmn + ")"}, " "))
|
||||
return "--"
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(country), name, "(" + plmn + ")"}, " "))
|
||||
}
|
||||
@@ -2318,6 +2328,11 @@ func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig,
|
||||
}
|
||||
|
||||
func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig, method string, payload any, result any) error {
|
||||
// Telegram polling is a long-lived notification channel and must use the
|
||||
// same administrator-configured destination exceptions as test messages,
|
||||
// SMS pushes and automatic-task notifications. This keeps SSRF protection
|
||||
// enabled while allowing explicit DNS Fake-IP ranges such as 198.18/15.
|
||||
ctx = bot.notificationDestinationContext(ctx)
|
||||
base, err := validateTelegramAPIURL(ctx, config.BaseURL, config.Token, method)
|
||||
if err != nil {
|
||||
return redactTelegramError(err, config.Token)
|
||||
@@ -2360,6 +2375,13 @@ func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *telegramBot) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if bot.server == nil {
|
||||
return ctx
|
||||
}
|
||||
return bot.server.notificationDestinationContext(ctx)
|
||||
}
|
||||
|
||||
func (bot *telegramBot) sendText(ctx context.Context, config telegramRuntimeConfig, chatID int64, text string, replyMarkup any) error {
|
||||
target := config.ChatID
|
||||
if chatID != 0 {
|
||||
|
||||
@@ -57,6 +57,17 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPollingAcceptsFakeIPWithoutWebAccessAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{mode: "internal"}}}
|
||||
ctx := bot.notificationDestinationContext(context.Background())
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil {
|
||||
t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err)
|
||||
}
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://169.254.169.254", "123456:test-token", "getUpdates"); err == nil {
|
||||
t.Fatal("metadata address became reachable through Telegram allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTelegramCommand(t *testing.T) {
|
||||
command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ")
|
||||
if command != "sms" || remainder != "EC20 +447700900123 hello world" {
|
||||
@@ -127,8 +138,11 @@ func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T
|
||||
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||
t.Fatalf("home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
|
||||
t.Fatalf("branded foreign-core 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") {
|
||||
t.Fatalf("profile brand overrode home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
||||
t.Fatalf("unknown home carrier did not fall back to SPN: %q", got)
|
||||
}
|
||||
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
|
||||
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
|
||||
|
||||
@@ -220,6 +220,124 @@ func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration19AcceptsDevelopmentDatabaseAndPreservesCardData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "development-schema.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 16; version++ {
|
||||
for _, statement := range migrationStatements(version) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create v%d schema: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := raw.ExecContext(ctx, `
|
||||
INSERT INTO devices (id, name, created_at, updated_at)
|
||||
VALUES ('ec20-1', 'EC20', 100, 100);
|
||||
INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at, custom_phone_number
|
||||
) VALUES (
|
||||
'8900000000000000019', 0, 1, 1,
|
||||
'ims', 'IPV4V6', 'user', 100, 100, '447700900019'
|
||||
);
|
||||
INSERT INTO card_apn_profiles (
|
||||
iccid, apn, ip_version, created_at, updated_at,
|
||||
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
|
||||
) VALUES (
|
||||
'8900000000000000019', 'mobile.example', 'IPV4V6', 100, 100,
|
||||
'user', 'secret', '', '234', '10', 'IP', 'PAP'
|
||||
);
|
||||
PRAGMA user_version = 16;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
policy, err := database.CardPolicy(ctx, "8900000000000000019")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.CustomPhoneNumber != "447700900019" {
|
||||
t.Fatalf("migrated card policy = %#v", policy)
|
||||
}
|
||||
profiles, err := database.ListCardAPNProfiles(ctx, "8900000000000000019")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(profiles) != 1 || profiles[0].APN != "mobile.example" || profiles[0].Username != "user" || profiles[0].AuthType != "PAP" {
|
||||
t.Fatalf("migrated APN profiles = %#v", profiles)
|
||||
}
|
||||
|
||||
var version int
|
||||
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 19 {
|
||||
t.Fatalf("schema version = %d, want 19", version)
|
||||
}
|
||||
for _, column := range []string{
|
||||
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
|
||||
"ims_transport", "ims_allow_imsi_derived_identity", "vowifi_eap_method",
|
||||
"vowifi_allow_sha1", "vowifi_use_modp1024",
|
||||
} {
|
||||
var count int
|
||||
if err := database.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM pragma_table_info('devices') WHERE name = ?
|
||||
`, column).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("migration 19 column %q count = %d", column, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration19AcceptsDevelopmentColumnsAlreadyPresent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "development-columns.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 18; version++ {
|
||||
for _, statement := range migrationStatements(version) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create v%d schema: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The development build added these columns while still reporting schema
|
||||
// 18. Migration 19 must treat that layout as compatible rather than fail on
|
||||
// the first duplicate ALTER TABLE statement.
|
||||
for _, statement := range migrationStatements(19) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create development column: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := raw.ExecContext(ctx, `PRAGMA user_version = 18`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
var version int
|
||||
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 19 {
|
||||
t.Fatalf("schema version = %d, want 19", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
|
||||
|
||||
@@ -264,6 +264,123 @@ func migrationStatements(version int) []string {
|
||||
return []string{
|
||||
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
case 17:
|
||||
// Some development builds recorded automatic-task support in an older
|
||||
// migration. Recreate the objects idempotently so databases from either
|
||||
// history converge before later migrations run.
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS automatic_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
device_id TEXT NOT NULL,
|
||||
profile_iccid TEXT NOT NULL,
|
||||
profile_aid TEXT NOT NULL DEFAULT '',
|
||||
task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')),
|
||||
environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')),
|
||||
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
|
||||
start_date TEXT NOT NULL,
|
||||
run_time TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'Local',
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10),
|
||||
notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)),
|
||||
next_run_at INTEGER NOT NULL,
|
||||
last_run_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_status TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`,
|
||||
`CREATE TABLE IF NOT EXISTS automatic_task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
scheduled_at INTEGER NOT NULL,
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
finished_at INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
|
||||
}
|
||||
case 18:
|
||||
// A short-lived schema lineage kept the original card-policy CHECK,
|
||||
// which rejected the supported VoWiFi + airplane-mode state. Rebuild
|
||||
// both related tables so all released and development databases converge
|
||||
// without dropping policies or custom APNs.
|
||||
return []string{
|
||||
`ALTER TABLE card_apn_profiles RENAME TO card_apn_profiles_v17`,
|
||||
`ALTER TABLE card_policies RENAME TO card_policies_v17`,
|
||||
`CREATE TABLE card_policies (
|
||||
iccid TEXT PRIMARY KEY,
|
||||
network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)),
|
||||
vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)),
|
||||
airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)),
|
||||
apn TEXT NOT NULL DEFAULT '',
|
||||
ip_version TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
custom_phone_number TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at, custom_phone_number
|
||||
) SELECT
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at, custom_phone_number
|
||||
FROM card_policies_v17`,
|
||||
`CREATE TABLE card_apn_profiles_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
iccid TEXT NOT NULL,
|
||||
apn TEXT NOT NULL,
|
||||
ip_version TEXT NOT NULL DEFAULT 'IPV4V6' CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
proxy TEXT NOT NULL DEFAULT '',
|
||||
mcc TEXT NOT NULL DEFAULT '',
|
||||
mnc TEXT NOT NULL DEFAULT '',
|
||||
roaming_ip_version TEXT NOT NULL DEFAULT 'IP' CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6')),
|
||||
auth_type TEXT NOT NULL DEFAULT 'NONE' CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP')),
|
||||
UNIQUE (iccid, apn, ip_version),
|
||||
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
|
||||
)`,
|
||||
`INSERT INTO card_apn_profiles_new
|
||||
SELECT id, iccid, apn, ip_version, created_at, updated_at,
|
||||
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
|
||||
FROM card_apn_profiles_v17`,
|
||||
`DROP TABLE card_apn_profiles_v17`,
|
||||
`DROP TABLE card_policies_v17`,
|
||||
`ALTER TABLE card_apn_profiles_new RENAME TO card_apn_profiles`,
|
||||
`CREATE INDEX card_apn_profiles_iccid_idx ON card_apn_profiles(iccid, id)`,
|
||||
}
|
||||
case 19:
|
||||
// Compatibility columns written by the Qualcomm/IMS development build.
|
||||
// The stable server may leave them unused, but retaining them makes a
|
||||
// database created by that build safely readable after an upgrade.
|
||||
return []string{
|
||||
`ALTER TABLE devices ADD COLUMN ims_apn TEXT NOT NULL DEFAULT 'ims'`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_private_identity TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_public_identity TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_sms_center TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_transport TEXT NOT NULL DEFAULT 'tcp'`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_allow_imsi_derived_identity INTEGER NOT NULL DEFAULT 1 CHECK (ims_allow_imsi_derived_identity IN (0, 1))`,
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_eap_method TEXT NOT NULL DEFAULT 'aka'`,
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`,
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 16
|
||||
const schemaVersion = 19
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
@@ -123,7 +123,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
|
||||
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
|
||||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
|
||||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
|
||||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
|
||||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin")) ||
|
||||
(nextVersion == 19 && strings.Contains(statement, "ADD COLUMN"))
|
||||
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -22,3 +28,26 @@ func TestAssetNamesFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAssetWithProgressVerifiesPublishedSize(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("vocat"), 4096)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
var destination bytes.Buffer
|
||||
asset := &Asset{Name: "vocat-test", BrowserDownloadURL: server.URL, Size: int64(len(payload))}
|
||||
if err := downloadAssetWithProgress(context.Background(), logger, asset, "", &destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(destination.Bytes(), payload) {
|
||||
t.Fatal("downloaded asset content differs")
|
||||
}
|
||||
|
||||
asset.Size++
|
||||
if err := downloadAssetWithProgress(context.Background(), logger, asset, "", io.Discard); err == nil {
|
||||
t.Fatal("download with a mismatched published size succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Release mirrors the subset of the GitHub releases API response that the
|
||||
@@ -40,6 +43,23 @@ const (
|
||||
DefaultRepository = "MengMengCode/VoCat"
|
||||
)
|
||||
|
||||
var githubHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// LatestRelease fetches the newest published release for repo (form
|
||||
// "owner/name"). A non-empty token is sent as a Bearer header, which is
|
||||
// required for private repositories and lifts the unauthenticated rate limit.
|
||||
@@ -61,7 +81,7 @@ func LatestRelease(ctx context.Context, repo, token string) (*Release, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: fetch latest release: %w", err)
|
||||
}
|
||||
@@ -120,7 +140,7 @@ func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: download asset: %w", err)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"vocat/internal/buildinfo"
|
||||
@@ -149,7 +151,7 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
}()
|
||||
|
||||
logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL)
|
||||
if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
|
||||
if err := downloadAssetWithProgress(ctx, logger, asset, opts.Token, tmp); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
@@ -206,6 +208,68 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
return nil
|
||||
}
|
||||
|
||||
type downloadProgressWriter struct {
|
||||
destination io.Writer
|
||||
downloaded atomic.Int64
|
||||
}
|
||||
|
||||
func (writer *downloadProgressWriter) Write(data []byte) (int, error) {
|
||||
written, err := writer.destination.Write(data)
|
||||
writer.downloaded.Add(int64(written))
|
||||
return written, err
|
||||
}
|
||||
|
||||
func downloadAssetWithProgress(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
asset *Asset,
|
||||
token string,
|
||||
destination io.Writer,
|
||||
) error {
|
||||
progress := &downloadProgressWriter{destination: destination}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
downloaded := progress.downloaded.Load()
|
||||
percent := float64(0)
|
||||
if asset.Size > 0 {
|
||||
percent = float64(downloaded) * 100 / float64(asset.Size)
|
||||
}
|
||||
logger.Info(
|
||||
"download progress",
|
||||
"asset", asset.Name,
|
||||
"downloaded", downloaded,
|
||||
"total", asset.Size,
|
||||
"percent", fmt.Sprintf("%.1f", percent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := downloadAsset(ctx, asset.BrowserDownloadURL, token, progress)
|
||||
close(done)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asset.Size > 0 && progress.downloaded.Load() != asset.Size {
|
||||
return fmt.Errorf(
|
||||
"update: asset size mismatch for %s: downloaded %d bytes, expected %d",
|
||||
asset.Name,
|
||||
progress.downloaded.Load(),
|
||||
asset.Size,
|
||||
)
|
||||
}
|
||||
logger.Info("download completed", "asset", asset.Name, "bytes", progress.downloaded.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExecutable catches incompatible architectures and missing dynamic
|
||||
// loaders before the working installation is touched. A valid checksum alone
|
||||
// cannot detect those packaging errors.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const att310280EPDG = "epdg.epc.att.net"
|
||||
|
||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
||||
// VoWiFi access gateway.
|
||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
switch {
|
||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
||||
return "234", "15", true
|
||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
||||
// the initial VoWiFi provisioning path.
|
||||
return "234", "30", true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
||||
// drift between protocol layers.
|
||||
func IsATT310280(identity SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
||||
}
|
||||
|
||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
if strings.TrimSpace(identity.EPDG) != "" {
|
||||
return identity
|
||||
}
|
||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func standardEPDGHostname(mcc, mnc string) string {
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
for len(mnc) < 3 {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
|
||||
mnc,
|
||||
strings.TrimSpace(mcc),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package vowifi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iccid string
|
||||
imsi string
|
||||
wantMCC string
|
||||
wantMNC string
|
||||
wantAssigned bool
|
||||
}{
|
||||
{name: "XeSIM Lebara route", iccid: "8944160000000000001", imsi: "204047000000001", wantMCC: "234", wantMNC: "15", wantAssigned: true},
|
||||
{name: "CTExcel initial route", iccid: "8944300000000000001", imsi: "234336000000001", wantMCC: "234", wantMNC: "30", wantAssigned: true},
|
||||
{name: "XeSIM ICCID without matching subscription", iccid: "8944160000000000001", imsi: "204041000000001"},
|
||||
{name: "similar ICCID must not match", iccid: "8944100000000000001", imsi: "204047000000001"},
|
||||
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336000000001"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
|
||||
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
|
||||
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
})
|
||||
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
|
||||
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
|
||||
t.Fatalf("route ePDG = %q", identity.EPDG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
||||
if !IsATT310280(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"}) {
|
||||
t.Fatal("AT&T 310/280 identity was not recognized")
|
||||
}
|
||||
for _, identity := range []SIMIdentity{
|
||||
{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "280"},
|
||||
{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "28"},
|
||||
{IMSI: "310280000000001", HomeMCC: "311", HomeMNC: "280"},
|
||||
} {
|
||||
if IsATT310280(identity) {
|
||||
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
-10
@@ -97,9 +97,10 @@ type ec20RadioCheckpoint struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ SIMIdentityReader = (*EC20Adapter)(nil)
|
||||
_ AKAProvider = (*EC20Adapter)(nil)
|
||||
_ RadioController = (*EC20Adapter)(nil)
|
||||
_ SIMIdentityReader = (*EC20Adapter)(nil)
|
||||
_ AKAProvider = (*EC20Adapter)(nil)
|
||||
_ PreferredAKAProvider = (*EC20Adapter)(nil)
|
||||
_ RadioController = (*EC20Adapter)(nil)
|
||||
)
|
||||
|
||||
func NewEC20Adapter(
|
||||
@@ -172,6 +173,7 @@ func (adapter *EC20Adapter) ReadIdentity(
|
||||
HomeMCC: homeMCC,
|
||||
HomeMNC: homeMNC,
|
||||
}
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[iccid] = ec20SIMBinding{
|
||||
deviceID: deviceID,
|
||||
@@ -208,6 +210,11 @@ func (adapter *EC20Adapter) readHomePLMN(
|
||||
iccid string,
|
||||
imsi string,
|
||||
) (string, string, error) {
|
||||
// AT&T 310/280 is a three-digit MNC. Prefer the assigned subscription
|
||||
// prefix when EF_AD is stale or ambiguous after a profile switch.
|
||||
if strings.HasPrefix(strings.TrimSpace(imsi), "310280") {
|
||||
return "310", "280", nil
|
||||
}
|
||||
mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID)
|
||||
if efErr == nil {
|
||||
if len(imsi) < 3+mncLength {
|
||||
@@ -238,9 +245,10 @@ func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
||||
prefix string
|
||||
mncLength int
|
||||
}{
|
||||
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
|
||||
{prefix: "23415", mncLength: 2}, // Vodafone UK.
|
||||
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
|
||||
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
|
||||
{prefix: "23415", mncLength: 2}, // Vodafone UK.
|
||||
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
|
||||
{prefix: "310280", mncLength: 3}, // AT&T / RedPocket GSMA.
|
||||
}
|
||||
for _, assignment := range assignments {
|
||||
if strings.HasPrefix(imsi, assignment.prefix) {
|
||||
@@ -391,11 +399,46 @@ func (adapter *EC20Adapter) Authenticate(
|
||||
ctx context.Context,
|
||||
identity SIMIdentity,
|
||||
challenge AKAChallenge,
|
||||
) (AKAResult, error) {
|
||||
return adapter.authenticateWithApplication(ctx, identity, challenge, "")
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) AuthenticateWithPreference(
|
||||
ctx context.Context,
|
||||
identity SIMIdentity,
|
||||
challenge AKAChallenge,
|
||||
preference string,
|
||||
) (AKAResult, error) {
|
||||
return adapter.authenticateWithApplication(ctx, identity, challenge, preference)
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) authenticateWithApplication(
|
||||
ctx context.Context,
|
||||
identity SIMIdentity,
|
||||
challenge AKAChallenge,
|
||||
preference string,
|
||||
) (AKAResult, error) {
|
||||
binding, err := adapter.bindingFor(identity)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
|
||||
aid, application, err := adapter.discoverPreferredAKAApplication(
|
||||
ctx,
|
||||
binding.deviceID,
|
||||
isimAIDPrefix,
|
||||
"ISIM",
|
||||
)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
binding.aid = aid
|
||||
binding.application = application
|
||||
binding.basicChannel = false
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[binding.iccid] = binding
|
||||
adapter.mu.Unlock()
|
||||
}
|
||||
if binding.aid == "" {
|
||||
if _, err := adapter.CheckReady(ctx, identity); err != nil {
|
||||
return AKAResult{}, err
|
||||
@@ -405,6 +448,14 @@ func (adapter *EC20Adapter) Authenticate(
|
||||
return AKAResult{}, err
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
|
||||
return AKAResult{}, fmt.Errorf(
|
||||
"%w: ISIM strict requested, selected %s (%s)",
|
||||
ErrEC20ApplicationAbsent,
|
||||
binding.application,
|
||||
binding.aid,
|
||||
)
|
||||
}
|
||||
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
@@ -907,6 +958,28 @@ func (adapter *EC20Adapter) discoverAKAApplication(
|
||||
return usimAIDPrefix, "USIM", nil
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) discoverPreferredAKAApplication(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
aidPrefix string,
|
||||
application string,
|
||||
) (string, string, error) {
|
||||
response, err := adapter.execute(ctx, deviceID, "AT+CUAD")
|
||||
if err == nil {
|
||||
data, parseErr := parseCUADData(response)
|
||||
if parseErr == nil {
|
||||
for _, candidate := range collectApplicationAIDs(data) {
|
||||
if strings.HasPrefix(candidate, aidPrefix) {
|
||||
return candidate, application, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// AT+CUAD is optional. Returning the standard AID prefix still lets CCHO
|
||||
// perform the authoritative application probe on older EC20 firmware.
|
||||
return aidPrefix, application, nil
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) openLogicalChannel(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
@@ -1146,12 +1219,27 @@ func parseCRSMData(response modem.Response) ([]byte, error) {
|
||||
}
|
||||
|
||||
func parseCUADData(response modem.Response) ([]byte, error) {
|
||||
fields := parseCSV(valueAfterATPrefix(response, "+CUAD:"))
|
||||
if len(fields) == 0 {
|
||||
// EC20 firmware may split the BER-TLV stream across adjacent quoted chunks
|
||||
// and continuation lines. Concatenating every hex fragment prevents an ISIM
|
||||
// AID after a USIM entry from being silently discarded.
|
||||
var encoded strings.Builder
|
||||
collect := false
|
||||
for _, line := range response.Lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(strings.ToUpper(line), "+CUAD:") {
|
||||
collect = true
|
||||
line = strings.TrimSpace(line[len("+CUAD:"):])
|
||||
} else if !collect {
|
||||
continue
|
||||
}
|
||||
for _, fragment := range quotedHexFragments(line) {
|
||||
encoded.WriteString(fragment)
|
||||
}
|
||||
}
|
||||
if encoded.Len() == 0 {
|
||||
return nil, errors.New("CUAD response has no data")
|
||||
}
|
||||
value := fields[len(fields)-1]
|
||||
data, err := hex.DecodeString(strings.Trim(value, `"`))
|
||||
data, err := hex.DecodeString(encoded.String())
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, errors.New("CUAD response data is invalid")
|
||||
}
|
||||
@@ -1161,11 +1249,48 @@ func parseCUADData(response modem.Response) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func quotedHexFragments(line string) []string {
|
||||
var fragments []string
|
||||
for {
|
||||
start := strings.IndexByte(line, '"')
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
line = line[start+1:]
|
||||
end := strings.IndexByte(line, '"')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
fragment := strings.ToUpper(strings.TrimSpace(line[:end]))
|
||||
line = line[end+1:]
|
||||
if fragment == "" || len(fragment)%2 != 0 {
|
||||
continue
|
||||
}
|
||||
valid := true
|
||||
for _, character := range fragment {
|
||||
if (character < '0' || character > '9') && (character < 'A' || character > 'F') {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
fragments = append(fragments, fragment)
|
||||
}
|
||||
}
|
||||
return fragments
|
||||
}
|
||||
|
||||
func collectApplicationAIDs(data []byte) []string {
|
||||
var result []string
|
||||
var walk func([]byte)
|
||||
walk = func(value []byte) {
|
||||
for len(value) > 0 {
|
||||
for len(value) > 0 && value[0] == 0xff {
|
||||
value = value[1:]
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return
|
||||
}
|
||||
tag, constructed, body, consumed, err := decodeBERTLV(value)
|
||||
if err != nil || consumed == 0 {
|
||||
return
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -421,6 +422,7 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
|
||||
"204040123456789": "204/04",
|
||||
"234150123456789": "234/15",
|
||||
"234870123456789": "234/87",
|
||||
"310280000000001": "310/280",
|
||||
}
|
||||
for imsi, want := range tests {
|
||||
mcc, mnc, ok := assignedHomePLMN(imsi)
|
||||
@@ -430,6 +432,23 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEC20AdapterTreatsATT310280AsThreeDigitMNC(t *testing.T) {
|
||||
t.Parallel()
|
||||
transcript := &ec20Transcript{t: t, steps: identityTranscriptStepsWithoutEFAD("310280000000001")}
|
||||
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadIdentity: %v", err)
|
||||
}
|
||||
if identity.HomeMCC != "310" || identity.HomeMNC != "280" {
|
||||
t.Fatalf("home PLMN = %s/%s, want 310/280", identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
transcript.assertDone()
|
||||
}
|
||||
|
||||
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -598,3 +617,78 @@ func synchronizationFailureUSIMResponse() []byte {
|
||||
raw = append(raw, auts...)
|
||||
return append(raw, 0x90, 0x00)
|
||||
}
|
||||
|
||||
func TestCollectApplicationAIDsSkipsCUADPadding(t *testing.T) {
|
||||
t.Parallel()
|
||||
response := modem.Response{Lines: []string{
|
||||
`+CUAD: "61184F10A0000000871002FFFFFFFF890302000050045553494DFFFFFFFFFFFFFFFFFFFFFFFF""61184F10A0000000871004FFFFFFFF890302000050044953494DFFFFFFFFFFFFFFFFFFFFFFFF"`,
|
||||
`"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"`,
|
||||
}}
|
||||
data, err := parseCUADData(response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aids := collectApplicationAIDs(data)
|
||||
want := []string{
|
||||
"A0000000871002FFFFFFFF8903020000",
|
||||
"A0000000871004FFFFFFFF8903020000",
|
||||
}
|
||||
if !reflect.DeepEqual(aids, want) {
|
||||
t.Fatalf("AIDs = %v, want %v", aids, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEC20AdapterISIMStrictUsesCUADFullAID(t *testing.T) {
|
||||
var challenge AKAChallenge
|
||||
for index := range challenge.RAND {
|
||||
challenge.RAND[index] = byte(index)
|
||||
challenge.AUTN[index] = byte(0xf0 + index)
|
||||
}
|
||||
authAPDU := buildUSIMAuthenticateAPDU(challenge)
|
||||
authCommand := fmt.Sprintf(
|
||||
`AT+CGLA=1,%d,"%s"`,
|
||||
len(authAPDU)*2,
|
||||
strings.ToUpper(hex.EncodeToString(authAPDU)),
|
||||
)
|
||||
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
|
||||
fullISIM := "A0000000871004FFFFFFFF8903020000"
|
||||
cuad := `61184F10A0000000871002FFFFFFFF890302000050045553494D61184F10A0000000871004FFFFFFFF890302000050044953494D`
|
||||
transcript := &ec20Transcript{
|
||||
t: t,
|
||||
steps: []ec20TranscriptStep{
|
||||
{command: "AT+CPIN?", lines: []string{"+CPIN: READY"}},
|
||||
{command: "AT+CIMI", lines: []string{"310280000000001"}},
|
||||
{command: "AT+CCID", lines: []string{"+CCID: 8901000000000000001"}},
|
||||
{command: "AT+CGSN", lines: []string{"860000000000001"}},
|
||||
{command: "AT+CUAD", lines: []string{`+CUAD: "` + cuad + `"`}},
|
||||
{command: "AT+CCID", lines: []string{"+CCID: 8901000000000000001"}},
|
||||
{command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 1"}},
|
||||
{
|
||||
command: authCommand,
|
||||
sensitive: true,
|
||||
lines: []string{fmt.Sprintf(
|
||||
`+CGLA: %d,"%s"`,
|
||||
len(encodedResponse),
|
||||
encodedResponse,
|
||||
)},
|
||||
},
|
||||
{command: "AT+CCHC=1"},
|
||||
},
|
||||
}
|
||||
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadIdentity: %v", err)
|
||||
}
|
||||
result, err := adapter.AuthenticateWithPreference(context.Background(), identity, challenge, "isim_strict")
|
||||
if err != nil {
|
||||
t.Fatalf("AuthenticateWithPreference: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) {
|
||||
t.Fatalf("RES = %x", result.RES)
|
||||
}
|
||||
transcript.assertDone()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
|
||||
configInternalIPv4Address = 1
|
||||
configInternalIPv4DNS = 3
|
||||
configApplicationVersion = 7
|
||||
configInternalIPv6Address = 8
|
||||
configInternalIPv6DNS = 10
|
||||
configPCSCFIPv4Address = 20
|
||||
@@ -186,6 +187,9 @@ func configurationRequest() payload {
|
||||
configInternalIPv6DNS,
|
||||
configPCSCFIPv4Address,
|
||||
configPCSCFIPv6Address,
|
||||
// Android's IKE library always appends APPLICATION_VERSION to the
|
||||
// initial configuration request, even when the value is empty.
|
||||
configApplicationVersion,
|
||||
}
|
||||
body := []byte{configRequest, 0, 0, 0}
|
||||
for _, attribute := range attributes {
|
||||
|
||||
@@ -285,6 +285,7 @@ type akaClient struct {
|
||||
simIdentity vowifi.SIMIdentity
|
||||
provider vowifi.AKAProvider
|
||||
keys akaKeys
|
||||
lastResponseStage string
|
||||
challengeComplete bool
|
||||
resultIndication bool
|
||||
protectedSuccess bool
|
||||
@@ -298,7 +299,12 @@ func newAKAClient(identity vowifi.SIMIdentity, provider vowifi.AKAProvider) (*ak
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &akaClient{identity: nai, simIdentity: identity, provider: provider}, nil
|
||||
return &akaClient{
|
||||
identity: nai,
|
||||
simIdentity: identity,
|
||||
provider: provider,
|
||||
lastResponseStage: "in the initial IKE_AUTH identity exchange",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction, error) {
|
||||
@@ -308,9 +314,9 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
|
||||
}
|
||||
switch packet.Code {
|
||||
case eapFailure:
|
||||
stage := "before the SIM AKA challenge (identity or subscription rejected)"
|
||||
stage := client.lastResponseStage
|
||||
if client.challengeComplete {
|
||||
stage = "after the SIM AKA response (AKA result or subscription rejected)"
|
||||
stage = "after the SIM AKA challenge response"
|
||||
}
|
||||
return eapAction{}, fmt.Errorf("%w %s", vowifi.ErrEAPAuthenticationRejected, stage)
|
||||
case eapSuccess:
|
||||
@@ -333,6 +339,7 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
|
||||
Type: eapTypeIdentity,
|
||||
Data: client.identity,
|
||||
})
|
||||
client.lastResponseStage = "after EAP-Response/Identity"
|
||||
return eapAction{Response: response}, err
|
||||
case eapTypeAKA:
|
||||
return client.handleAKARequest(ctx, packet)
|
||||
@@ -405,6 +412,7 @@ func (client *akaClient) respondAKAIdentity(identifier uint8, attributes []akaAt
|
||||
Type: eapTypeAKA,
|
||||
Data: data,
|
||||
})
|
||||
client.lastResponseStage = "after EAP-Response/AKA-Identity"
|
||||
return eapAction{Response: response}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -106,16 +106,34 @@ func TestEAPFailureReportsAuthenticationStage(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.handle(context.Background(), failure)
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "before the SIM AKA challenge") {
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "initial IKE_AUTH identity exchange") {
|
||||
t.Fatalf("pre-challenge failure = %v", err)
|
||||
}
|
||||
client.challengeComplete = true
|
||||
_, err = client.handle(context.Background(), failure)
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA response") {
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA challenge response") {
|
||||
t.Fatalf("post-challenge failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAPFailureReportsIdentityResponseStage(t *testing.T) {
|
||||
client, err := newAKAClient(testSIMIdentity(), &testAKAProvider{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identityRequest, _ := marshalEAPPacket(eapPacket{
|
||||
Code: eapRequest, Identifier: 4, Type: eapTypeIdentity,
|
||||
})
|
||||
if _, err := client.handle(context.Background(), identityRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failure, _ := marshalEAPPacket(eapPacket{Code: eapFailure, Identifier: 5})
|
||||
_, err = client.handle(context.Background(), failure)
|
||||
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after EAP-Response/Identity") {
|
||||
t.Fatalf("identity-stage failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAPAKAChallengeTypedSIMAndMAC(t *testing.T) {
|
||||
result := vowifi.AKAResult{
|
||||
RES: bytes.Repeat([]byte{0x91}, 8),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package ike
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deviceIdentityRequested reports the 3GPP DEVICE_IDENTITY request defined by
|
||||
// TS 24.302. Android remembers this request and answers it in a later EAP
|
||||
// IKE_AUTH request, but only after authenticating the ePDG.
|
||||
func deviceIdentityRequested(payloads []payload) (bool, error) {
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, _, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if kind == notifyDeviceIdentity {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func deviceIdentityNotify(identity string) (payload, error) {
|
||||
identity = strings.TrimSpace(identity)
|
||||
if (len(identity) != 15 && len(identity) != 16) || !decimalDigits(identity) {
|
||||
return payload{}, errors.New("ike: device identity must contain 15 or 16 digits")
|
||||
}
|
||||
identityType := byte(1) // IMEI
|
||||
if len(identity) == 16 {
|
||||
identityType = 2 // IMEISV
|
||||
}
|
||||
data := make([]byte, 11)
|
||||
// TS 24.302 Figure 8.2.9.2: this inner length excludes its own two
|
||||
// octets, and is therefore 9 for an IMEI/IMEISV value.
|
||||
binary.BigEndian.PutUint16(data[:2], 9)
|
||||
data[2] = identityType
|
||||
for index := 0; index < 8; index++ {
|
||||
low := identity[index*2] - '0'
|
||||
high := byte(0x0f)
|
||||
if index*2+1 < len(identity) {
|
||||
high = identity[index*2+1] - '0'
|
||||
}
|
||||
data[index+3] = high<<4 | low
|
||||
}
|
||||
return makeNotify(notifyDeviceIdentity, data), nil
|
||||
}
|
||||
|
||||
func decimalDigits(value string) bool {
|
||||
for _, digit := range value {
|
||||
if digit < '0' || digit > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ike
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceIdentityNotifyMatchesAndroidEncoding(t *testing.T) {
|
||||
item, err := deviceIdentityNotify("123456789012345")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []byte{0, 9, 1, 0x21, 0x43, 0x65, 0x87, 0x09, 0x21, 0x43, 0xf5}
|
||||
if kind != notifyDeviceIdentity || !bytes.Equal(data, want) {
|
||||
t.Fatalf("DEVICE_IDENTITY = %d/%x, want %d/%x", kind, data, notifyDeviceIdentity, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityNotifyAcceptsIMEISV(t *testing.T) {
|
||||
item, err := deviceIdentityNotify("1234567890123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, data, _ := parseNotify(item)
|
||||
if data[2] != 2 || data[10] != 0x65 {
|
||||
t.Fatalf("IMEISV data = %x", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityRequested(t *testing.T) {
|
||||
requested, err := deviceIdentityRequested([]payload{makeNotify(notifyDeviceIdentity, nil)})
|
||||
if err != nil || !requested {
|
||||
t.Fatalf("deviceIdentityRequested() = %v, %v", requested, err)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
|
||||
group := uint16(dhMODP2048)
|
||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
if legacyFirst {
|
||||
group = dhMODP1024
|
||||
}
|
||||
@@ -245,6 +246,23 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cleanupPendingIKE := true
|
||||
cleanupMessageID := uint32(2)
|
||||
defer func() {
|
||||
if cleanupPendingIKE {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = sendIKESADelete(
|
||||
cleanupContext,
|
||||
transport,
|
||||
ikeSuite,
|
||||
keys,
|
||||
initiatorSPI,
|
||||
responseHeader.ResponderSPI,
|
||||
cleanupMessageID,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
var childInboundSPIBytes [4]byte
|
||||
if err := fillNonzero(provider.config.Random, childInboundSPIBytes[:]); err != nil {
|
||||
@@ -259,7 +277,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
requestedIDr := payload{Type: payloadIDr, Body: append([]byte{2, 0, 0, 0}, []byte(provider.config.APN)...)}
|
||||
tsi := dualStackTrafficSelectors(payloadTSi)
|
||||
tsr := dualStackTrafficSelectors(payloadTSr)
|
||||
firstAuthPayloads := buildInitialEAPOnlyAuth(idi, requestedIDr, childOfferBody, tsi, tsr)
|
||||
firstAuthPayloads := buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, advertiseEAPOnly)
|
||||
authHeader := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
@@ -292,15 +310,19 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
initiatorNonce,
|
||||
ikeSuite,
|
||||
keys.SKpr,
|
||||
serverName,
|
||||
"", // Android Iwlan enables IKE_OPTION_ACCEPT_ANY_REMOTE_ID.
|
||||
serverName,
|
||||
provider.config.RootCAs,
|
||||
provider.config.ResponderPublicKey,
|
||||
true, // RFC 5998 EAP-only authentication defers responder AUTH.
|
||||
true, // Some ePDGs, including O2 Germany, implicitly defer AUTH without accepting the RFC 5998 notify.
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceIdentityPending, err := deviceIdentityRequested(authResponsePayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messageID := uint32(1)
|
||||
currentPayloads := authResponsePayloads
|
||||
for round := 0; round < 10; round++ {
|
||||
@@ -319,16 +341,29 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, errors.New("ike: EAP state machine produced no response")
|
||||
}
|
||||
messageID++
|
||||
cleanupMessageID = messageID + 1
|
||||
requestPayloads := []payload{{Type: payloadEAP, Body: action.Response}}
|
||||
if deviceIdentityPending && responderAUTH == vowifi.ResponderAUTHVerified {
|
||||
deviceIdentity, identityErr := deviceIdentityNotify(request.Identity.IMEI)
|
||||
if identityErr == nil {
|
||||
requestPayloads = append(requestPayloads, deviceIdentity)
|
||||
}
|
||||
}
|
||||
eapRequest, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
Exchange: exchangeIKEAuth,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, []payload{{Type: payloadEAP, Body: action.Response}}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
||||
return nil, notifyErr
|
||||
} else if requested {
|
||||
deviceIdentityPending = true
|
||||
}
|
||||
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -358,6 +393,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, err
|
||||
}
|
||||
messageID++
|
||||
cleanupMessageID = messageID + 1
|
||||
finalRequest, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
@@ -383,17 +419,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
}
|
||||
finalAUTHs := payloadsOfType(finalPayloads, payloadAuth)
|
||||
if len(finalAUTHs) != 1 {
|
||||
return nil, fmt.Errorf("%w: final EAP-only response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
|
||||
return nil, fmt.Errorf("%w: final EAP response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
|
||||
}
|
||||
if len(responderID.Body) == 0 {
|
||||
return nil, errors.New("ike: EAP-only exchange has no initial ePDG IDr for the responder AUTH transcript")
|
||||
return nil, errors.New("ike: EAP exchange has no responder IDr for the AUTH transcript")
|
||||
}
|
||||
finalIDs := payloadsOfType(finalPayloads, payloadIDr)
|
||||
if len(finalIDs) > 1 {
|
||||
return nil, errors.New("ike: duplicate final responder IDr payload")
|
||||
}
|
||||
if len(finalIDs) == 1 {
|
||||
if err := validateFQDNIDr(finalIDs[0], provider.config.APN, "final APN"); err != nil {
|
||||
if err := validateFQDNIDr(finalIDs[0], "", "final responder"); err != nil {
|
||||
return nil, fmt.Errorf("ike: final APN IDr: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -472,9 +508,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
keys,
|
||||
initiatorSPI,
|
||||
responseHeader.ResponderSPI,
|
||||
messageID+1,
|
||||
natDetected,
|
||||
provider.config.KeepaliveInterval,
|
||||
)
|
||||
cleanupPendingIKE = false
|
||||
installed, err := provider.config.Installer.Install(ctx, ChildSAConfig{
|
||||
Name: name,
|
||||
OuterLocal: append(net.IP(nil), transport.LocalAddr().IP...),
|
||||
@@ -499,11 +537,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
Relay: relay,
|
||||
})
|
||||
if err != nil {
|
||||
_ = relay.Close()
|
||||
_ = relay.CloseWithDelete(ctx)
|
||||
return nil, fmt.Errorf("ike: install CHILD_SA: %w", err)
|
||||
}
|
||||
if installed == nil {
|
||||
_ = relay.Close()
|
||||
_ = relay.CloseWithDelete(ctx)
|
||||
return nil, errors.New("ike: CHILD_SA installer returned a nil handle")
|
||||
}
|
||||
dataplaneMode := "unknown"
|
||||
@@ -553,6 +591,47 @@ func legacyIKEProfile(mcc, mnc string) bool {
|
||||
return plmn == "23415" || plmn == "2044"
|
||||
}
|
||||
|
||||
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
|
||||
// Android exposes the ePDG authentication method as carrier policy rather
|
||||
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
|
||||
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
|
||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
||||
}
|
||||
|
||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "2623"
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
childOfferBody []byte,
|
||||
tsi payload,
|
||||
tsr payload,
|
||||
eapOnly bool,
|
||||
) []payload {
|
||||
// Match Android's IkeSessionStateMachine.buildIkeAuthReq ordering. Some
|
||||
// carrier ePDGs inspect this first encrypted exchange before starting EAP.
|
||||
payloads := []payload{idi, requestedIDr}
|
||||
if eapOnly {
|
||||
payloads = append(payloads, makeNotify(notifyEAPOnlyAuth, nil))
|
||||
}
|
||||
payloads = append(payloads,
|
||||
makeNotify(notifyMOBIKESupported, nil),
|
||||
makeNotify(notifyInitialContact, nil),
|
||||
)
|
||||
return append(payloads,
|
||||
payload{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
|
||||
tsi,
|
||||
tsr,
|
||||
configurationRequest(),
|
||||
)
|
||||
}
|
||||
|
||||
func buildInitialEAPOnlyAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
@@ -560,15 +639,7 @@ func buildInitialEAPOnlyAuth(
|
||||
tsi payload,
|
||||
tsr payload,
|
||||
) []payload {
|
||||
return []payload{
|
||||
idi,
|
||||
requestedIDr,
|
||||
makeNotify(notifyEAPOnlyAuth, nil),
|
||||
{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
|
||||
tsi,
|
||||
tsr,
|
||||
configurationRequest(),
|
||||
}
|
||||
return buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, true)
|
||||
}
|
||||
|
||||
func ikeOffer(group uint16, legacyFirst bool) proposal {
|
||||
@@ -877,7 +948,7 @@ func (session *Session) Close(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
if relay != nil {
|
||||
if err := relay.Close(); err != nil {
|
||||
if err := relay.CloseWithDelete(ctx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close session relay: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,17 @@ func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
}
|
||||
|
||||
type firstAuthCaptureTransport struct {
|
||||
t *testing.T
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
|
||||
@@ -96,8 +98,16 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
return nil, err
|
||||
}
|
||||
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
||||
if group != dhMODP1024 || len(ke.Body[4:]) != 128 {
|
||||
transport.t.Fatalf("Vodafone init KE = group %d length %d", group, len(ke.Body[4:]))
|
||||
wantGroup := transport.wantGroup
|
||||
if wantGroup == 0 {
|
||||
wantGroup = dhMODP1024
|
||||
}
|
||||
wantKELength := 128
|
||||
if wantGroup == dhMODP2048 {
|
||||
wantKELength = 256
|
||||
}
|
||||
if group != wantGroup || len(ke.Body[4:]) != wantKELength {
|
||||
transport.t.Fatalf("init KE = group %d length %d, want group %d length %d", group, len(ke.Body[4:]), wantGroup, wantKELength)
|
||||
}
|
||||
serverDH, err := newDHExchange(group, constantReader{value: 0x77})
|
||||
if err != nil {
|
||||
@@ -108,6 +118,15 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
return nil, err
|
||||
}
|
||||
transport.suite = legacyTestSuite()
|
||||
if group == dhMODP2048 {
|
||||
transport.suite = negotiatedSuite{
|
||||
EncryptionID: encryptionAESCBC,
|
||||
EncryptionBits: 128,
|
||||
PRFID: prfHMACSHA256,
|
||||
IntegrityID: integrityHMACSHA256_128,
|
||||
DHID: dhMODP2048,
|
||||
}
|
||||
}
|
||||
transport.spii = header.InitiatorSPI
|
||||
transport.spir = [8]byte{0x80, 1, 2, 3, 4, 5, 6, 7}
|
||||
transport.nonceI = append([]byte(nil), nonce.Body...)
|
||||
@@ -128,9 +147,9 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
Protocol: protocolIKE,
|
||||
Transforms: []transform{
|
||||
{Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128},
|
||||
{Type: transformPRF, ID: prfHMACSHA1},
|
||||
{Type: transformIntegrity, ID: integrityHMACSHA1_96},
|
||||
{Type: transformDH, ID: dhMODP1024},
|
||||
{Type: transformPRF, ID: transport.suite.PRFID},
|
||||
{Type: transformIntegrity, ID: transport.suite.IntegrityID},
|
||||
{Type: transformDH, ID: group},
|
||||
},
|
||||
}})
|
||||
keBody := make([]byte, 4+len(serverDH.Public))
|
||||
@@ -180,8 +199,8 @@ func (transport *firstAuthCaptureTransport) observeFirstAuth(packet []byte) erro
|
||||
foundEAPOnly = true
|
||||
}
|
||||
}
|
||||
if !foundEAPOnly {
|
||||
transport.t.Fatal("first IKE_AUTH omitted EAP_ONLY_AUTHENTICATION")
|
||||
if foundEAPOnly != transport.wantEAPOnly {
|
||||
transport.t.Fatalf("first IKE_AUTH EAP_ONLY_AUTHENTICATION present=%v, want %v", foundEAPOnly, transport.wantEAPOnly)
|
||||
}
|
||||
for _, kind := range []uint8{payloadIDi, payloadSA, payloadTSi, payloadTSr, payloadCP} {
|
||||
if _, err := onePayload(payloads, kind); err != nil {
|
||||
@@ -212,7 +231,7 @@ func (unusedInstaller) Install(context.Context, ChildSAConfig) (ChildSAHandle, e
|
||||
}
|
||||
|
||||
func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t}
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: true}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
@@ -250,5 +269,44 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-o2",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8949200000000000000",
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if !errors.Is(err, errFirstAuthObserved) {
|
||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
||||
}
|
||||
if capture.calls != 2 || capture.floated || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
var _ io.Reader = constantReader{}
|
||||
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
|
||||
|
||||
@@ -15,6 +15,7 @@ type sessionRelay struct {
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
deleteID uint32
|
||||
natt bool
|
||||
keepalive time.Duration
|
||||
|
||||
@@ -33,6 +34,7 @@ func newSessionRelay(
|
||||
keys ikeKeys,
|
||||
initiatorSPI [8]byte,
|
||||
responderSPI [8]byte,
|
||||
deleteMessageID uint32,
|
||||
natt bool,
|
||||
keepalive time.Duration,
|
||||
) *sessionRelay {
|
||||
@@ -46,6 +48,7 @@ func newSessionRelay(
|
||||
keys: keys,
|
||||
spii: initiatorSPI,
|
||||
spir: responderSPI,
|
||||
deleteID: deleteMessageID,
|
||||
natt: natt,
|
||||
keepalive: keepalive,
|
||||
ctx: ctx,
|
||||
@@ -214,6 +217,53 @@ func (relay *sessionRelay) Close() error {
|
||||
return errors.Join(relay.terminalErrorIfFailure(), transportErr)
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) CloseWithDelete(ctx context.Context) error {
|
||||
deleteErr := relay.sendIKEDelete(ctx)
|
||||
return errors.Join(deleteErr, relay.Close())
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) sendIKEDelete(ctx context.Context) error {
|
||||
return sendIKESADelete(ctx, relay.transport, relay.suite, relay.keys, relay.spii, relay.spir, relay.deleteID)
|
||||
}
|
||||
|
||||
func sendIKESADelete(
|
||||
ctx context.Context,
|
||||
transport datagramTransport,
|
||||
suite negotiatedSuite,
|
||||
keys ikeKeys,
|
||||
initiatorSPI [8]byte,
|
||||
responderSPI [8]byte,
|
||||
messageID uint32,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
// Teardown is often called with the operation context already canceled.
|
||||
// Give the protocol-level release a short independent chance to leave.
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
request, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responderSPI,
|
||||
Exchange: exchangeInformational,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, []payload{{
|
||||
Type: payloadDelete,
|
||||
Body: []byte{protocolIKE, 0, 0, 0},
|
||||
}}, suite, keys.SKei, keys.SKai, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ike: build IKE SA delete: %w", err)
|
||||
}
|
||||
if err := transport.SendSessionPacket(ctx, request, true); err != nil {
|
||||
return fmt.Errorf("ike: send IKE SA delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) terminalErrorIfFailure() error {
|
||||
relay.mu.Lock()
|
||||
defer relay.mu.Unlock()
|
||||
|
||||
@@ -119,6 +119,7 @@ func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) {
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
9,
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
@@ -156,7 +157,7 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) {
|
||||
}
|
||||
spii := [8]byte{1}
|
||||
spir := [8]byte{2}
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, true, time.Hour)
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour)
|
||||
defer relay.Close()
|
||||
|
||||
esp := []byte{0, 0, 0, 9, 0, 0, 0, 1, 0xaa}
|
||||
@@ -201,6 +202,47 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelaySendsEncryptedIKESADelete(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
suite := legacyTestSuite()
|
||||
keys := ikeKeys{
|
||||
SKai: bytes.Repeat([]byte{0x11}, 20),
|
||||
SKar: bytes.Repeat([]byte{0x12}, 20),
|
||||
SKei: bytes.Repeat([]byte{0x13}, 16),
|
||||
SKer: bytes.Repeat([]byte{0x14}, 16),
|
||||
}
|
||||
spii := [8]byte{1}
|
||||
spir := [8]byte{2}
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour)
|
||||
defer relay.Close()
|
||||
|
||||
if err := relay.sendIKEDelete(context.Background()); err != nil {
|
||||
t.Fatalf("sendIKEDelete() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case sent := <-transport.sent:
|
||||
if !sent.ike {
|
||||
t.Fatal("IKE SA delete was sent as ESP")
|
||||
}
|
||||
header, payloads, err := decryptPayloads(sent.data, suite, keys.SKei, keys.SKai)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt IKE SA delete: %v", err)
|
||||
}
|
||||
if header.Exchange != exchangeInformational || header.MessageID != 9 || header.Flags != flagInitiator {
|
||||
t.Fatalf("IKE SA delete header = %#v", header)
|
||||
}
|
||||
item, err := onePayload(payloads, payloadDelete)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(item.Body, []byte{protocolIKE, 0, 0, 0}) {
|
||||
t.Fatalf("IKE SA delete body = %x", item.Body)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("relay did not send IKE SA delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelaySendsNATKeepalive(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
relay := newSessionRelay(
|
||||
@@ -209,6 +251,7 @@ func TestSessionRelaySendsNATKeepalive(t *testing.T) {
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
9,
|
||||
true,
|
||||
10*time.Millisecond,
|
||||
)
|
||||
@@ -233,6 +276,7 @@ func TestSessionRelayDropsDelayedIKEPacketFromPreviousSA(t *testing.T) {
|
||||
ikeKeys{},
|
||||
spii,
|
||||
spir,
|
||||
9,
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
payloadAuth = 39
|
||||
payloadNonce = 40
|
||||
payloadNotify = 41
|
||||
payloadDelete = 42
|
||||
payloadTSi = 44
|
||||
payloadTSr = 45
|
||||
payloadEncrypted = 46
|
||||
@@ -54,11 +55,14 @@ const (
|
||||
dhMODP2048 = 14
|
||||
transformAttributeKeyLen = 14
|
||||
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
notifyInitialContact = 16384
|
||||
notifyMOBIKESupported = 16396
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyDeviceIdentity = 41101
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -3,6 +3,7 @@ package ike
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
@@ -60,7 +61,7 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
dualStackTrafficSelectors(payloadTSi),
|
||||
dualStackTrafficSelectors(payloadTSr),
|
||||
)
|
||||
if len(payloads) != 7 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
if len(payloads) != 9 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
t.Fatalf("initial auth payload order = %#v", payloads)
|
||||
}
|
||||
if got := string(payloads[1].Body[4:]); got != "ims" || payloads[1].Body[0] != 2 {
|
||||
@@ -68,11 +69,25 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
}
|
||||
kind, data, err := parseNotify(payloads[2])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
t.Fatalf("parseNotify(EAP_ONLY_AUTHENTICATION) error = %v", err)
|
||||
}
|
||||
if kind != notifyEAPOnlyAuth || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want EAP_ONLY_AUTHENTICATION", kind, data)
|
||||
}
|
||||
kind, data, err = parseNotify(payloads[3])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
}
|
||||
if kind != notifyMOBIKESupported || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want MOBIKE_SUPPORTED", kind, data)
|
||||
}
|
||||
kind, data, err = parseNotify(payloads[4])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
}
|
||||
if kind != notifyInitialContact || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want INITIAL_CONTACT", kind, data)
|
||||
}
|
||||
for _, kind := range []uint8{payloadTSi, payloadTSr} {
|
||||
item, err := onePayload(payloads, kind)
|
||||
if err != nil {
|
||||
@@ -88,6 +103,83 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialStandardEAPAuthOmitsEAPOnlyNotify(t *testing.T) {
|
||||
idi := payload{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u'}}
|
||||
idr := payload{Type: payloadIDr, Body: []byte{2, 0, 0, 0, 'i', 'm', 's'}}
|
||||
payloads := buildInitialEAPAuth(
|
||||
idi,
|
||||
idr,
|
||||
[]byte{1, 2, 3},
|
||||
dualStackTrafficSelectors(payloadTSi),
|
||||
dualStackTrafficSelectors(payloadTSr),
|
||||
false,
|
||||
)
|
||||
if len(payloads) != 8 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
t.Fatalf("initial standard EAP payload order = %#v", payloads)
|
||||
}
|
||||
initialContact := 0
|
||||
mobikeSupported := 0
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, _, err := parseNotify(item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kind == notifyEAPOnlyAuth {
|
||||
t.Fatal("standard EAP initial request contains EAP_ONLY_AUTHENTICATION")
|
||||
}
|
||||
if kind == notifyInitialContact {
|
||||
initialContact++
|
||||
}
|
||||
if kind == notifyMOBIKESupported {
|
||||
mobikeSupported++
|
||||
}
|
||||
}
|
||||
if initialContact != 1 {
|
||||
t.Fatalf("standard EAP initial request INITIAL_CONTACT count = %d, want 1", initialContact)
|
||||
}
|
||||
if mobikeSupported != 1 {
|
||||
t.Fatalf("standard EAP initial request MOBIKE_SUPPORTED count = %d, want 1", mobikeSupported)
|
||||
}
|
||||
if payloads[2].Type != payloadNotify || payloads[3].Type != payloadNotify {
|
||||
t.Fatalf("standard EAP Android notify order = %#v", payloads[:4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
cp := configurationRequest()
|
||||
if cp.Type != payloadCP || len(cp.Body) < 4 || cp.Body[0] != configRequest {
|
||||
t.Fatalf("configuration request = %#v", cp)
|
||||
}
|
||||
var attributes []uint16
|
||||
for offset := 4; offset < len(cp.Body); {
|
||||
if offset+4 > len(cp.Body) {
|
||||
t.Fatalf("truncated attribute at %d", offset)
|
||||
}
|
||||
kind := uint16(cp.Body[offset])<<8 | uint16(cp.Body[offset+1])
|
||||
length := int(cp.Body[offset+2])<<8 | int(cp.Body[offset+3])
|
||||
if offset+4+length > len(cp.Body) {
|
||||
t.Fatalf("attribute %d exceeds payload", kind)
|
||||
}
|
||||
attributes = append(attributes, kind)
|
||||
offset += 4 + length
|
||||
}
|
||||
want := []uint16{1, 8, 3, 10, 20, 21, configApplicationVersion}
|
||||
if !slices.Equal(attributes, want) {
|
||||
t.Fatalf("configuration attributes = %v, want %v", attributes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
||||
epdg := payload{
|
||||
Type: payloadIDr,
|
||||
|
||||
@@ -168,6 +168,7 @@ func authenticateAKA(
|
||||
provider vowifi.AKAProvider,
|
||||
identity vowifi.SIMIdentity,
|
||||
challenge digestChallenge,
|
||||
preference string,
|
||||
) (akaMaterial, error) {
|
||||
nonce, err := decodeAKANonce(challenge.Nonce)
|
||||
if err != nil {
|
||||
@@ -178,9 +179,18 @@ func authenticateAKA(
|
||||
var akaChallenge vowifi.AKAChallenge
|
||||
copy(akaChallenge.RAND[:], nonce[:16])
|
||||
copy(akaChallenge.AUTN[:], nonce[16:32])
|
||||
result, err := provider.Authenticate(ctx, identity, akaChallenge)
|
||||
var result vowifi.AKAResult
|
||||
if preferred, ok := provider.(vowifi.PreferredAKAProvider); ok && strings.TrimSpace(preference) != "" {
|
||||
result, err = preferred.AuthenticateWithPreference(ctx, identity, akaChallenge, preference)
|
||||
} else {
|
||||
result, err = provider.Authenticate(ctx, identity, akaChallenge)
|
||||
}
|
||||
if err != nil {
|
||||
return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", err)
|
||||
application := "USIM"
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
|
||||
application = "ISIM"
|
||||
}
|
||||
return akaMaterial{}, fmt.Errorf("ims: %s AKA authentication failed: %w", application, err)
|
||||
}
|
||||
if result.SynchronizationFailure || len(result.AUTS) > 0 {
|
||||
if !result.SynchronizationFailure || len(result.AUTS) != 14 {
|
||||
|
||||
@@ -16,6 +16,21 @@ type recordingAKA struct {
|
||||
challenges []vowifi.AKAChallenge
|
||||
}
|
||||
|
||||
type recordingPreferredAKA struct {
|
||||
recordingAKA
|
||||
preference string
|
||||
}
|
||||
|
||||
func (aka *recordingPreferredAKA) AuthenticateWithPreference(
|
||||
ctx context.Context,
|
||||
identity vowifi.SIMIdentity,
|
||||
challenge vowifi.AKAChallenge,
|
||||
preference string,
|
||||
) (vowifi.AKAResult, error) {
|
||||
aka.preference = preference
|
||||
return aka.Authenticate(ctx, identity, challenge)
|
||||
}
|
||||
|
||||
func (aka *recordingAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) {
|
||||
return vowifi.AKAEvidence{Ready: true, Application: "usim"}, nil
|
||||
}
|
||||
@@ -60,6 +75,7 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) {
|
||||
aka,
|
||||
vowifi.SIMIdentity{IMSI: "001010123456789"},
|
||||
digestChallenge{Nonce: base64.StdEncoding.EncodeToString(nonceBytes)},
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
@@ -93,6 +109,7 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
|
||||
aka,
|
||||
vowifi.SIMIdentity{},
|
||||
digestChallenge{Nonce: nonce},
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
@@ -102,6 +119,26 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateAKAUsesPreferredApplicationWhenSupported(t *testing.T) {
|
||||
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
aka := &recordingPreferredAKA{recordingAKA: recordingAKA{
|
||||
result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}},
|
||||
}}
|
||||
_, err := authenticateAKA(
|
||||
context.Background(),
|
||||
aka,
|
||||
vowifi.SIMIdentity{IMSI: "310280000000001"},
|
||||
digestChallenge{Nonce: nonce},
|
||||
"isim_strict",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
}
|
||||
if aka.preference != "isim_strict" {
|
||||
t.Fatalf("preference = %q, want isim_strict", aka.preference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) {
|
||||
authorization := buildDigestAuthorization(
|
||||
digestChallenge{
|
||||
|
||||
+117
-12
@@ -271,13 +271,22 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||
privateDomain := domain
|
||||
publicDomain := domain
|
||||
if vowifi.IsATT310280(identity) {
|
||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||
// the generic 3GPP PLMN IMS domain.
|
||||
domain = "one.att.net"
|
||||
privateDomain = "private.att.net"
|
||||
publicDomain = "one.att.net"
|
||||
}
|
||||
privateIdentity := config.PrivateIdentity
|
||||
if privateIdentity == "" {
|
||||
privateIdentity = imsi + "@" + domain
|
||||
privateIdentity = imsi + "@" + privateDomain
|
||||
}
|
||||
publicIdentity := config.PublicIdentity
|
||||
if publicIdentity == "" {
|
||||
publicIdentity = "sip:" + imsi + "@" + domain
|
||||
publicIdentity = "sip:" + imsi + "@" + publicDomain
|
||||
}
|
||||
if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") ||
|
||||
!strings.Contains(privateIdentity, "@") ||
|
||||
@@ -534,15 +543,33 @@ func newSession(
|
||||
refreshCancel()
|
||||
return nil, errors.New("ims: protected local IP address is unavailable")
|
||||
}
|
||||
protectedClientPort := provider.config.ProtectedClientPort
|
||||
protectedServerPort := provider.config.ProtectedServerPort
|
||||
if vowifi.IsATT310280(request.Identity) && protectedServerPort == 0 {
|
||||
protectedServerPort = 6000
|
||||
}
|
||||
if securityEncryptionForIdentity(request.Identity) == "null" {
|
||||
if protectedClientPort == 0 {
|
||||
protectedClientPort = 5062
|
||||
}
|
||||
if protectedServerPort == 0 {
|
||||
protectedServerPort = 5063
|
||||
}
|
||||
}
|
||||
proposal, err := newSecurityProposal(
|
||||
localIP,
|
||||
provider.config.ProtectedClientPort,
|
||||
provider.config.ProtectedServerPort,
|
||||
protectedClientPort,
|
||||
protectedServerPort,
|
||||
)
|
||||
if err != nil {
|
||||
refreshCancel()
|
||||
return nil, err
|
||||
}
|
||||
proposal.encryption = securityEncryptionForIdentity(request.Identity)
|
||||
if vowifi.IsATT310280(request.Identity) {
|
||||
proposal.integrityAlgorithms = []string{"hmac-sha-1-96"}
|
||||
proposal.encryptionAlgorithmsList = []string{"aes-cbc"}
|
||||
}
|
||||
session.securityProposal = proposal
|
||||
protectedTCP, err := net.ListenTCP(
|
||||
"tcp",
|
||||
@@ -567,6 +594,21 @@ func newSession(
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||
if usesO2GermanyIMSProfile(identity) {
|
||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
}
|
||||
|
||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
return mcc+mnc == "2623"
|
||||
}
|
||||
|
||||
func (session *Session) abort() {
|
||||
session.refreshCancel()
|
||||
_ = session.conn.Close()
|
||||
@@ -635,6 +677,15 @@ func registrationRejectionError(response *sipResponse, phase string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// P-Debug-Info is carrier-generated but can contain subscriber identifiers.
|
||||
// Surface only a fixed classification for the O2 security-agreement error;
|
||||
// never copy the raw header into logs or API responses.
|
||||
for _, value := range response.values("P-Debug-Info") {
|
||||
if strings.Contains(strings.ToLower(value), "no matched security item") {
|
||||
message += "; carrier detail: no matched IMS security item"
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrRegistrationRejected, message)
|
||||
}
|
||||
|
||||
@@ -697,7 +748,11 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge)
|
||||
preference := ""
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
preference = "isim_strict"
|
||||
}
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -757,6 +812,10 @@ func (session *Session) buildRegister(
|
||||
authorizationHeader string,
|
||||
authorization string,
|
||||
) ([]byte, error) {
|
||||
att310280 := vowifi.IsATT310280(session.request.Identity)
|
||||
if att310280 {
|
||||
expires = 18400
|
||||
}
|
||||
branch, err := randomHex(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -775,6 +834,34 @@ func (session *Session) buildRegister(
|
||||
session.instanceID,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
)
|
||||
if att310280 {
|
||||
contact = fmt.Sprintf(
|
||||
`<sip:%s@%s;transport=%s>;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+
|
||||
`+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
|
||||
session.identity.user,
|
||||
contactAddress,
|
||||
session.transport,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
session.instanceID,
|
||||
)
|
||||
}
|
||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||
supported := "path, gruu"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
||||
if o2Germany {
|
||||
// Match the complete IMS capability set used by the previously working
|
||||
// VoHive client. O2 validates more of the initial UE security profile
|
||||
// than the other tested carriers do.
|
||||
supported = "path, gruu, outbound, sec-agree, 100rel, timer"
|
||||
allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS"
|
||||
}
|
||||
if att310280 {
|
||||
supported = "path,sec-agree,gruu"
|
||||
}
|
||||
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
|
||||
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
|
||||
userAgent = "SimAdmin VoWiFi"
|
||||
}
|
||||
lines := []string{
|
||||
"REGISTER " + requestURI + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
|
||||
@@ -786,14 +873,25 @@ func (session *Session) buildRegister(
|
||||
fmt.Sprintf("CSeq: %d REGISTER", cseq),
|
||||
"Contact: " + contact,
|
||||
fmt.Sprintf("Expires: %d", expires),
|
||||
"Supported: path, gruu",
|
||||
"Allow: REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS",
|
||||
"User-Agent: " + session.provider.config.UserAgent,
|
||||
"Supported: " + supported,
|
||||
"Allow: " + allow,
|
||||
"User-Agent: " + userAgent,
|
||||
}
|
||||
if o2Germany {
|
||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||
} else if att310280 {
|
||||
lines = append(lines,
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
`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",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
||||
)
|
||||
}
|
||||
if session.securityOffered() {
|
||||
lines = append(
|
||||
lines,
|
||||
"Security-Client: "+session.securityProposal.headerValue(),
|
||||
lines = append(lines,
|
||||
"Security-Client: "+session.securityClientValue(),
|
||||
"Require: sec-agree",
|
||||
"Proxy-Require: sec-agree",
|
||||
)
|
||||
@@ -1194,7 +1292,14 @@ func (session *Session) Close(ctx context.Context) error {
|
||||
session.closeInboundConnections()
|
||||
session.receiveDone.Wait()
|
||||
if session.ipsecHandle != nil {
|
||||
if err := session.ipsecHandle.Close(ctx); err != nil {
|
||||
// XFRM teardown is local and must still run when SIP deregistration has
|
||||
// consumed the caller's deadline. Use a fresh bounded context so a
|
||||
// service restart or Profile switch cannot strand the previous SIM's
|
||||
// transport-mode policies in the kernel.
|
||||
cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
err := session.ipsecHandle.Close(cleanupContext)
|
||||
cleanupCancel()
|
||||
if err != nil {
|
||||
cleanupErrors = append(cleanupErrors, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,138 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
SecurityMode: SecurityRequired,
|
||||
UserAgent: "vocat-test",
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "o2-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 101,
|
||||
spiServer: 102,
|
||||
portClient: 5062,
|
||||
portServer: 5063,
|
||||
encryption: "null",
|
||||
},
|
||||
}
|
||||
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
_, headers, err := parseTestRequest(packet)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTestRequest() error = %v", err)
|
||||
}
|
||||
if got, want := headers["security-client"], "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000000101;spi-s=0000000102;port-c=5062;port-s=5063"; got != want {
|
||||
t.Fatalf("Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
if headers["proxy-require"] != "sec-agree" || !strings.Contains(headers["authorization"], "integrity-protected=no") {
|
||||
t.Fatalf("initial O2 headers omitted standardized sec-agree/IMS-AKA fields: %#v", headers)
|
||||
}
|
||||
if got, want := headers["p-preferred-identity"], "<"+identities.public+">"; got != want {
|
||||
t.Fatalf("P-Preferred-Identity = %q, want %q", got, want)
|
||||
}
|
||||
for name, token := range map[string]string{
|
||||
"supported": "sec-agree",
|
||||
"allow": "MESSAGE",
|
||||
} {
|
||||
if !strings.Contains(headers[name], token) {
|
||||
t.Fatalf("%s = %q, want token %q", name, headers[name], token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
|
||||
identities, err := deriveIdentities(vowifi.SIMIdentity{
|
||||
IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280",
|
||||
}, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
if identities.domain != "one.att.net" ||
|
||||
identities.private != "[email protected]" ||
|
||||
identities.public != "sip:[email protected]" {
|
||||
t.Fatalf("AT&T identities = %#v", identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "att-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 1546543, spiServer: 1546542,
|
||||
portClient: 32773, portServer: 6000,
|
||||
integrityAlgorithms: []string{"hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"aes-cbc"},
|
||||
},
|
||||
}
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
request := string(packet)
|
||||
for _, want := range []string{
|
||||
"REGISTER sip:one.att.net SIP/2.0",
|
||||
"Expires: 18400",
|
||||
"Supported: path,sec-agree,gruu",
|
||||
"User-Agent: SimAdmin VoWiFi",
|
||||
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
|
||||
"P-Preferred-Identity: <sip:[email protected]>",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
|
||||
`username="[email protected]"`,
|
||||
`uri="sip:one.att.net"`,
|
||||
} {
|
||||
if !strings.Contains(request, want) {
|
||||
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||
var callID string
|
||||
for step := 0; step < 3; step++ {
|
||||
|
||||
+212
-47
@@ -12,6 +12,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
type SecurityMode string
|
||||
@@ -39,6 +41,12 @@ var (
|
||||
type IPSecSAConfig struct {
|
||||
LocalIP net.IP
|
||||
RemoteIP net.IP
|
||||
// IntegrityAlgorithm is the negotiated 3GPP Security-Server alg value.
|
||||
// Supported values are hmac-md5-96 and hmac-sha-1-96.
|
||||
IntegrityAlgorithm string
|
||||
// EncryptionAlgorithm is the negotiated 3GPP Security-Server ealg value.
|
||||
// Supported values are null, des-ede3-cbc, and aes-cbc.
|
||||
EncryptionAlgorithm string
|
||||
|
||||
UEClientSPI uint32
|
||||
UEServerSPI uint32
|
||||
@@ -63,10 +71,14 @@ type IPSecSAInstaller interface {
|
||||
}
|
||||
|
||||
type securityProposal struct {
|
||||
spiClient uint32
|
||||
spiServer uint32
|
||||
portClient int
|
||||
portServer int
|
||||
spiClient uint32
|
||||
spiServer uint32
|
||||
portClient int
|
||||
portServer int
|
||||
encryption string
|
||||
fallbackEncryption string
|
||||
integrityAlgorithms []string
|
||||
encryptionAlgorithmsList []string
|
||||
}
|
||||
|
||||
func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredServerPort int) (securityProposal, error) {
|
||||
@@ -96,21 +108,98 @@ func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredSer
|
||||
return securityProposal{}, errors.New("ims: protected UE ports must be distinct non-standard SIP ports")
|
||||
}
|
||||
return securityProposal{
|
||||
spiClient: spiClient,
|
||||
spiServer: spiServer,
|
||||
portClient: portClient,
|
||||
portServer: portServer,
|
||||
spiClient: spiClient,
|
||||
spiServer: spiServer,
|
||||
portClient: portClient,
|
||||
portServer: portServer,
|
||||
integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (proposal securityProposal) headerValue() string {
|
||||
return fmt.Sprintf(
|
||||
"ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d",
|
||||
proposal.spiClient,
|
||||
proposal.spiServer,
|
||||
proposal.portClient,
|
||||
proposal.portServer,
|
||||
)
|
||||
// TS 33.203 defines spi-c and spi-s as exactly 10 decimal digits. Keep the
|
||||
// complete mechanism explicit even where ESP/null defaults would permit a
|
||||
// shorter form; this is the interoperable handset/IMS profile.
|
||||
values := make([]string, 0, len(proposal.integrities())*len(proposal.encryptionAlgorithms()))
|
||||
index := 0
|
||||
for _, integrity := range proposal.integrities() {
|
||||
for _, encryption := range proposal.encryptionAlgorithms() {
|
||||
preference := fmt.Sprintf("0.%03d", 999-index)
|
||||
if index == 0 {
|
||||
preference = "1.000"
|
||||
}
|
||||
values = append(values, fmt.Sprintf(
|
||||
"ipsec-3gpp;q=%s;alg=%s;prot=esp;mod=trans;ealg=%s;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d",
|
||||
preference,
|
||||
integrity,
|
||||
encryption,
|
||||
proposal.spiClient,
|
||||
proposal.spiServer,
|
||||
proposal.portClient,
|
||||
proposal.portServer,
|
||||
))
|
||||
index++
|
||||
}
|
||||
}
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
func (session *Session) securityClientValue() string {
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
return fmt.Sprintf(
|
||||
"ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=%d; spi-s=%d; port-c=%d; port-s=%d",
|
||||
session.securityProposal.spiClient,
|
||||
session.securityProposal.spiServer,
|
||||
session.securityProposal.portClient,
|
||||
session.securityProposal.portServer,
|
||||
)
|
||||
}
|
||||
return session.securityProposal.headerValue()
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithm() string {
|
||||
if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") {
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithms() []string {
|
||||
if len(proposal.encryptionAlgorithmsList) > 0 {
|
||||
return append([]string(nil), proposal.encryptionAlgorithmsList...)
|
||||
}
|
||||
algorithms := []string{proposal.encryptionAlgorithm()}
|
||||
fallback := strings.ToLower(strings.TrimSpace(proposal.fallbackEncryption))
|
||||
if (fallback == "aes-cbc" || fallback == "null") && fallback != algorithms[0] {
|
||||
algorithms = append(algorithms, fallback)
|
||||
}
|
||||
return algorithms
|
||||
}
|
||||
|
||||
func (proposal securityProposal) integrities() []string {
|
||||
if len(proposal.integrityAlgorithms) > 0 {
|
||||
return append([]string(nil), proposal.integrityAlgorithms...)
|
||||
}
|
||||
return []string{"hmac-sha-1-96"}
|
||||
}
|
||||
|
||||
func (proposal securityProposal) supportsIntegrity(integrity string) bool {
|
||||
for _, offered := range proposal.integrities() {
|
||||
if strings.EqualFold(integrity, offered) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (proposal securityProposal) supportsEncryption(encryption string) bool {
|
||||
for _, offered := range proposal.encryptionAlgorithms() {
|
||||
if strings.EqualFold(encryption, offered) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func randomSPI(exclude uint32) (uint32, error) {
|
||||
@@ -198,10 +287,10 @@ func parseSecurityAgreement(values []string, proposal securityProposal) (securit
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(mechanism.name, "ipsec-3gpp") ||
|
||||
!strings.EqualFold(mechanism.algorithm, "hmac-sha-1-96") ||
|
||||
!proposal.supportsIntegrity(mechanism.algorithm) ||
|
||||
!strings.EqualFold(mechanism.protocol, "esp") ||
|
||||
!strings.EqualFold(mechanism.mode, "trans") ||
|
||||
!strings.EqualFold(mechanism.encryption, "aes-cbc") {
|
||||
!proposal.supportsEncryption(mechanism.encryption) {
|
||||
continue
|
||||
}
|
||||
if mechanism.spiClient == 0 || mechanism.spiServer == 0 ||
|
||||
@@ -349,16 +438,48 @@ func preferenceValue(value string) (int, error) {
|
||||
return numeric, nil
|
||||
}
|
||||
|
||||
func expandIPSecKeys(ck []byte, ik []byte) (encryption []byte, integrity []byte, err error) {
|
||||
func expandIPSecKeys(ck []byte, ik []byte, encryptionAlgorithm, integrityAlgorithm string) (encryption []byte, integrity []byte, err error) {
|
||||
if len(ck) != 16 || len(ik) != 16 {
|
||||
return nil, nil, errors.New("ims: AKA did not return 16-byte CK and IK")
|
||||
}
|
||||
encryption = append([]byte(nil), ck...)
|
||||
integrity = make([]byte, 20)
|
||||
copy(integrity, ik)
|
||||
switch strings.ToLower(strings.TrimSpace(encryptionAlgorithm)) {
|
||||
case "null":
|
||||
encryption = nil
|
||||
case "aes-cbc", "":
|
||||
encryption = append([]byte(nil), ck...)
|
||||
case "des-ede3-cbc":
|
||||
encryption = append(encryption, ck...)
|
||||
encryption = append(encryption, ck[:8]...)
|
||||
for index, value := range encryption {
|
||||
encryption[index] = withOddDESParity(value)
|
||||
}
|
||||
default:
|
||||
return nil, nil, errors.New("ims: unsupported ipsec-3gpp encryption algorithm")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(integrityAlgorithm)) {
|
||||
case "hmac-md5-96":
|
||||
integrity = append([]byte(nil), ik...)
|
||||
case "hmac-sha-1-96", "":
|
||||
integrity = make([]byte, 20)
|
||||
copy(integrity, ik)
|
||||
default:
|
||||
return nil, nil, errors.New("ims: unsupported ipsec-3gpp integrity algorithm")
|
||||
}
|
||||
return encryption, integrity, nil
|
||||
}
|
||||
|
||||
func withOddDESParity(value byte) byte {
|
||||
value &^= 1
|
||||
ones := 0
|
||||
for bits := value; bits != 0; bits >>= 1 {
|
||||
ones += int(bits & 1)
|
||||
}
|
||||
if ones%2 == 0 {
|
||||
value |= 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type xfrmOperation struct {
|
||||
description string
|
||||
arguments []string
|
||||
@@ -382,20 +503,31 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
||||
{"outbound UE-server to P-CSCF-client state", config.LocalIP, config.RemoteIP, config.PCSCFClientSPI, serverPairReqID(config)},
|
||||
}
|
||||
for _, state := range states {
|
||||
arguments := []string{
|
||||
"xfrm", "state", "add",
|
||||
"src", state.source.String(),
|
||||
"dst", state.destination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", state.spi),
|
||||
"reqid", strconv.FormatUint(uint64(state.reqid), 10),
|
||||
"mode", "transport",
|
||||
"replay-window", "32",
|
||||
"auth-trunc", xfrmIntegrityAlgorithm(config), "0x" + hex.EncodeToString(config.IntegrityKey), "96",
|
||||
}
|
||||
switch ipsecEncryptionAlgorithm(config) {
|
||||
case "aes-cbc":
|
||||
arguments = append(arguments, "enc", "cbc(aes)", "0x"+hex.EncodeToString(config.EncryptionKey))
|
||||
case "des-ede3-cbc":
|
||||
arguments = append(arguments, "enc", "cbc(des3_ede)", "0x"+hex.EncodeToString(config.EncryptionKey))
|
||||
default:
|
||||
// Linux requires an explicit encryption transform for ESP even when
|
||||
// 3GPP negotiates ealg=null. iproute2 must receive a genuinely empty
|
||||
// key argument; the textual value "0x" is rejected by XFRM as EINVAL.
|
||||
arguments = append(arguments, "enc", "cipher_null", "")
|
||||
}
|
||||
operations = append(operations, xfrmOperation{
|
||||
description: state.description,
|
||||
arguments: []string{
|
||||
"xfrm", "state", "add",
|
||||
"src", state.source.String(),
|
||||
"dst", state.destination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", state.spi),
|
||||
"reqid", strconv.FormatUint(uint64(state.reqid), 10),
|
||||
"mode", "transport",
|
||||
"replay-window", "32",
|
||||
"auth-trunc", "hmac(sha1)", "0x" + hex.EncodeToString(config.IntegrityKey), "96",
|
||||
"enc", "cbc(aes)", "0x" + hex.EncodeToString(config.EncryptionKey),
|
||||
},
|
||||
arguments: arguments,
|
||||
})
|
||||
}
|
||||
for _, flow := range xfrmFlows(config) {
|
||||
@@ -588,12 +720,43 @@ func validateIPSecSAConfig(config IPSecSAConfig) error {
|
||||
config.PCSCFClientPort == config.PCSCFServerPort {
|
||||
return errors.New("ims: client and server protected ports must differ")
|
||||
}
|
||||
if len(config.EncryptionKey) != 16 || len(config.IntegrityKey) != 20 {
|
||||
if ipsecEncryptionAlgorithm(config) != "null" && ipsecEncryptionAlgorithm(config) != "aes-cbc" && ipsecEncryptionAlgorithm(config) != "des-ede3-cbc" {
|
||||
return errors.New("ims: unsupported ipsec-3gpp encryption algorithm")
|
||||
}
|
||||
if ipsecIntegrityAlgorithm(config) != "hmac-md5-96" && ipsecIntegrityAlgorithm(config) != "hmac-sha-1-96" {
|
||||
return errors.New("ims: unsupported ipsec-3gpp integrity algorithm")
|
||||
}
|
||||
wantEncryptionKey := map[string]int{"null": 0, "aes-cbc": 16, "des-ede3-cbc": 24}[ipsecEncryptionAlgorithm(config)]
|
||||
wantIntegrityKey := map[string]int{"hmac-md5-96": 16, "hmac-sha-1-96": 20}[ipsecIntegrityAlgorithm(config)]
|
||||
if len(config.EncryptionKey) != wantEncryptionKey || len(config.IntegrityKey) != wantIntegrityKey {
|
||||
return errors.New("ims: ipsec-3gpp key length is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ipsecIntegrityAlgorithm(config IPSecSAConfig) string {
|
||||
algorithm := strings.ToLower(strings.TrimSpace(config.IntegrityAlgorithm))
|
||||
if algorithm == "" {
|
||||
return "hmac-sha-1-96"
|
||||
}
|
||||
return algorithm
|
||||
}
|
||||
|
||||
func xfrmIntegrityAlgorithm(config IPSecSAConfig) string {
|
||||
if ipsecIntegrityAlgorithm(config) == "hmac-md5-96" {
|
||||
return "hmac(md5)"
|
||||
}
|
||||
return "hmac(sha1)"
|
||||
}
|
||||
|
||||
func ipsecEncryptionAlgorithm(config IPSecSAConfig) string {
|
||||
algorithm := strings.ToLower(strings.TrimSpace(config.EncryptionAlgorithm))
|
||||
if algorithm == "" {
|
||||
return "aes-cbc"
|
||||
}
|
||||
return algorithm
|
||||
}
|
||||
|
||||
func cloneIPSecSAConfig(config IPSecSAConfig) IPSecSAConfig {
|
||||
config.LocalIP = append(net.IP(nil), config.LocalIP...)
|
||||
config.RemoteIP = append(net.IP(nil), config.RemoteIP...)
|
||||
@@ -657,7 +820,7 @@ func (session *Session) activateIPSec(
|
||||
if !session.securityOffered() {
|
||||
return ErrIPSecAgreementRequired
|
||||
}
|
||||
encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik)
|
||||
encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik, agreement.selected.encryption, agreement.selected.algorithm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -671,18 +834,20 @@ func (session *Session) activateIPSec(
|
||||
}
|
||||
selected := agreement.selected
|
||||
config := IPSecSAConfig{
|
||||
LocalIP: localIP,
|
||||
RemoteIP: remoteIP,
|
||||
UEClientSPI: session.securityProposal.spiClient,
|
||||
UEServerSPI: session.securityProposal.spiServer,
|
||||
PCSCFClientSPI: selected.spiClient,
|
||||
PCSCFServerSPI: selected.spiServer,
|
||||
UEClientPort: session.securityProposal.portClient,
|
||||
UEServerPort: session.securityProposal.portServer,
|
||||
PCSCFClientPort: selected.portClient,
|
||||
PCSCFServerPort: selected.portServer,
|
||||
EncryptionKey: encryptionKey,
|
||||
IntegrityKey: integrityKey,
|
||||
LocalIP: localIP,
|
||||
RemoteIP: remoteIP,
|
||||
IntegrityAlgorithm: selected.algorithm,
|
||||
EncryptionAlgorithm: selected.encryption,
|
||||
UEClientSPI: session.securityProposal.spiClient,
|
||||
UEServerSPI: session.securityProposal.spiServer,
|
||||
PCSCFClientSPI: selected.spiClient,
|
||||
PCSCFServerSPI: selected.spiServer,
|
||||
UEClientPort: session.securityProposal.portClient,
|
||||
UEServerPort: session.securityProposal.portServer,
|
||||
PCSCFClientPort: selected.portClient,
|
||||
PCSCFServerPort: selected.portServer,
|
||||
EncryptionKey: encryptionKey,
|
||||
IntegrityKey: integrityKey,
|
||||
}
|
||||
handle, err := session.provider.installer.Install(ctx, config)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,8 +23,9 @@ type fakeIPSecInstaller struct {
|
||||
}
|
||||
|
||||
type fakeIPSecHandle struct {
|
||||
mu sync.Mutex
|
||||
closeCount int
|
||||
mu sync.Mutex
|
||||
closeCount int
|
||||
closeContextErr error
|
||||
}
|
||||
|
||||
func (installer *fakeIPSecInstaller) Install(
|
||||
@@ -53,10 +54,11 @@ func (installer *fakeIPSecInstaller) installed() []IPSecSAConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
func (handle *fakeIPSecHandle) Close(context.Context) error {
|
||||
func (handle *fakeIPSecHandle) Close(ctx context.Context) error {
|
||||
handle.mu.Lock()
|
||||
defer handle.mu.Unlock()
|
||||
handle.closeCount++
|
||||
handle.closeContextErr = ctx.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,6 +68,38 @@ func (handle *fakeIPSecHandle) closes() int {
|
||||
return handle.closeCount
|
||||
}
|
||||
|
||||
func (handle *fakeIPSecHandle) contextError() error {
|
||||
handle.mu.Lock()
|
||||
defer handle.mu.Unlock()
|
||||
return handle.closeContextErr
|
||||
}
|
||||
|
||||
func TestSessionCloseCleansIPSecAfterCallerDeadline(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer server.Close()
|
||||
refreshDone := make(chan struct{})
|
||||
close(refreshDone)
|
||||
handle := &fakeIPSecHandle{}
|
||||
session := &Session{
|
||||
conn: client,
|
||||
refreshCancel: func() {},
|
||||
refreshDone: refreshDone,
|
||||
ipsecHandle: handle,
|
||||
calls: make(map[string]*imsCall),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := session.Close(ctx); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if handle.closes() != 1 {
|
||||
t.Fatalf("IPsec close count = %d, want 1", handle.closes())
|
||||
}
|
||||
if err := handle.contextError(); err != nil {
|
||||
t.Fatalf("IPsec cleanup inherited expired caller context: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderNegotiatesIPSecAndRegistersOverProtectedTCP(t *testing.T) {
|
||||
localIP := net.ParseIP("127.0.0.1")
|
||||
remoteIP := net.ParseIP("127.0.0.2")
|
||||
@@ -386,7 +420,12 @@ func serveProtectedRegistrar(
|
||||
return result, fmt.Errorf("initial sec-agree headers = %#v", headers)
|
||||
}
|
||||
result.securityClient = headers["security-client"]
|
||||
proposal, err := parseSecurityMechanism(result.securityClient)
|
||||
offers := splitHeaderValues([]string{result.securityClient})
|
||||
if len(offers) != 6 {
|
||||
_ = initialConnection.Close()
|
||||
return result, fmt.Errorf("initial Security-Client offers = %d, want 6", len(offers))
|
||||
}
|
||||
proposal, err := parseSecurityMechanism(offers[0])
|
||||
if err != nil {
|
||||
_ = initialConnection.Close()
|
||||
return result, fmt.Errorf("parse initial Security-Client: %w", err)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
@@ -36,6 +38,51 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"}
|
||||
if got := securityEncryptionForIdentity(identity); got != "null" {
|
||||
t.Fatalf("O2 security encryption = %q, want null", got)
|
||||
}
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001, spiServer: 1002,
|
||||
portClient: 40666, portServer: 55610,
|
||||
encryption: securityEncryptionForIdentity(identity),
|
||||
}
|
||||
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
||||
t.Fatalf("O2 Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=null;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
if _, err := parseSecurityAgreement([]string{selected}, proposal); err != nil {
|
||||
t.Fatalf("O2 null Security-Server rejected: %v", err)
|
||||
}
|
||||
|
||||
identity.HomeMNC = "02"
|
||||
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
||||
t.Fatalf("non-O2 security encryption = %q, want aes-cbc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityAgreementAcceptsO2FallbackEncryption(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
spiServer: 1002,
|
||||
portClient: 5062,
|
||||
portServer: 5063,
|
||||
encryption: "null",
|
||||
fallbackEncryption: "aes-cbc",
|
||||
}
|
||||
value := "ipsec-3gpp;q=0.5;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
agreement, err := parseSecurityAgreement([]string{value}, proposal)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSecurityAgreement(aes-cbc fallback) error = %v", err)
|
||||
}
|
||||
if got := agreement.selected.encryption; got != "aes-cbc" {
|
||||
t.Fatalf("selected encryption = %q, want aes-cbc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSecurityAgreementSkipsIncompleteCarrierAlternatives(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
@@ -121,7 +168,7 @@ func TestParseSecurityAgreementFailsClosed(t *testing.T) {
|
||||
func TestExpandIPSecKeys(t *testing.T) {
|
||||
ck := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
|
||||
ik := []byte{16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}
|
||||
encryption, integrity, err := expandIPSecKeys(ck, ik)
|
||||
encryption, integrity, err := expandIPSecKeys(ck, ik, "aes-cbc", "hmac-sha-1-96")
|
||||
if err != nil {
|
||||
t.Fatalf("expandIPSecKeys() error = %v", err)
|
||||
}
|
||||
@@ -139,6 +186,71 @@ func TestExpandIPSecKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandIPSecKeysForAndroidAlgorithmSet(t *testing.T) {
|
||||
ck := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}
|
||||
ik := []byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38}
|
||||
tripleDES, md5Key, err := expandIPSecKeys(ck, ik, "des-ede3-cbc", "hmac-md5-96")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tripleDES) != 24 || len(md5Key) != 16 {
|
||||
t.Fatalf("3DES/MD5 key lengths = %d/%d", len(tripleDES), len(md5Key))
|
||||
}
|
||||
for _, value := range tripleDES {
|
||||
ones := 0
|
||||
for bits := value; bits != 0; bits >>= 1 {
|
||||
ones += int(bits & 1)
|
||||
}
|
||||
if ones%2 != 1 {
|
||||
t.Fatalf("3DES byte %02x does not have odd parity", value)
|
||||
}
|
||||
}
|
||||
nullKey, sha1Key, err := expandIPSecKeys(ck, ik, "null", "hmac-sha-1-96")
|
||||
if err != nil || len(nullKey) != 0 || len(sha1Key) != 20 {
|
||||
t.Fatalf("null/SHA1 keys = %d/%d, %v", len(nullKey), len(sha1Key), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidIMSProposalOffersAllDefaultAlgorithms(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001, spiServer: 1002, portClient: 5062, portServer: 5063,
|
||||
integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"},
|
||||
}
|
||||
header := proposal.headerValue()
|
||||
for _, combination := range []string{
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=null",
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=des-ede3-cbc",
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=aes-cbc",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=des-ede3-cbc",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc",
|
||||
} {
|
||||
if !strings.Contains(header, combination) {
|
||||
t.Fatalf("Android IMS Security-Client omitted %q: %s", combination, header)
|
||||
}
|
||||
}
|
||||
if got := len(splitHeaderValues([]string{header})); got != 6 {
|
||||
t.Fatalf("Security-Client mechanism count = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSecurityProposalUsesAndroidDefaultsForEveryCarrier(t *testing.T) {
|
||||
proposal, err := newSecurityProposal(net.ParseIP("127.0.0.1"), 45062, 45063)
|
||||
if err != nil {
|
||||
t.Fatalf("newSecurityProposal() error = %v", err)
|
||||
}
|
||||
if got, want := proposal.integrities(), []string{"hmac-md5-96", "hmac-sha-1-96"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("integrity algorithms = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := proposal.encryptionAlgorithms(), []string{"null", "des-ede3-cbc", "aes-cbc"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("encryption algorithms = %v, want %v", got, want)
|
||||
}
|
||||
if got := len(splitHeaderValues([]string{proposal.headerValue()})); got != 6 {
|
||||
t.Fatalf("Security-Client mechanism count = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
@@ -209,6 +321,46 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanSupportsIntegrityOnlyESP(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.EncryptionAlgorithm = "null"
|
||||
config.EncryptionKey = nil
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXFRMInstallPlan(null) error = %v", err)
|
||||
}
|
||||
for index, operation := range install[:4] {
|
||||
joined := strings.Join(operation.arguments, " ")
|
||||
if !strings.Contains(joined, "auth-trunc hmac(sha1)") {
|
||||
t.Fatalf("null state %d omitted integrity: %v", index, operation.arguments)
|
||||
}
|
||||
if !containsArguments(operation.arguments, "enc", "cipher_null", "") {
|
||||
t.Fatalf("null state %d omitted Linux NULL cipher: %v", index, operation.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanSupportsAndroidMD5AndTripleDES(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.IntegrityAlgorithm = "hmac-md5-96"
|
||||
config.EncryptionAlgorithm = "des-ede3-cbc"
|
||||
config.IntegrityKey = []byte(strings.Repeat("\x22", 16))
|
||||
config.EncryptionKey = []byte(strings.Repeat("\x11", 24))
|
||||
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXFRMInstallPlan() error = %v", err)
|
||||
}
|
||||
for index, operation := range install[:4] {
|
||||
if !containsArguments(operation.arguments, "auth-trunc", "hmac(md5)", "0x"+strings.Repeat("22", 16), "96") {
|
||||
t.Fatalf("state %d omitted HMAC-MD5-96 transform: %v", index, operation.arguments)
|
||||
}
|
||||
if !containsArguments(operation.arguments, "enc", "cbc(des3_ede)", "0x"+strings.Repeat("11", 24)) {
|
||||
t.Fatalf("state %d omitted 3DES-CBC transform: %v", index, operation.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIPSecSAConfigRejectsDuplicateSPI(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.PCSCFServerSPI = config.UEClientSPI
|
||||
|
||||
@@ -28,21 +28,37 @@ func (resolver ProxyResolver) Resolve(
|
||||
}
|
||||
deviceID := strings.TrimSpace(request.DeviceID)
|
||||
iccid := strings.TrimSpace(request.ICCID)
|
||||
if deviceID == "" || iccid == "" {
|
||||
if deviceID == "" {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
var upstreamID string
|
||||
if iccid != "" {
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if err == nil {
|
||||
upstreamID = binding.UpstreamProxyID
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
|
||||
if upstreamID == "" {
|
||||
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
||||
if !found {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
||||
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
if ruleErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
||||
}
|
||||
upstreamID = rule.UpstreamProxyID
|
||||
}
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf(
|
||||
"load upstream proxy %q for device %s: %w",
|
||||
binding.UpstreamProxyID,
|
||||
upstreamID,
|
||||
deviceID,
|
||||
err,
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
|
||||
func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
|
||||
ID: "legacy", Name: "Legacy", Addr: "127.0.0.1:1080", Enabled: true,
|
||||
@@ -98,8 +98,42 @@ func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.Mode != vowifi.ProxyModeDirect {
|
||||
t.Fatalf("route = %#v, want direct", route)
|
||||
if route.Mode != vowifi.ProxyModeSOCKS5 || route.ID != "legacy" {
|
||||
t.Fatalf("route = %#v, want MCC country fallback", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
||||
database := testStore(t)
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
{ID: "profile", Name: "Profile", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "country", Name: "Country", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), proxy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := database.UpsertCountryRule(context.Background(), store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "country", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "profile" {
|
||||
t.Fatalf("route = %#v, want ICCID binding", route)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -645,18 +645,13 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
|
||||
}
|
||||
return strings.ToLower(configured), nil
|
||||
}
|
||||
if IsATT310280(identity) {
|
||||
return att310280EPDG, nil
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
for len(mnc) < 3 {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
|
||||
mnc,
|
||||
strings.TrimSpace(identity.HomeMCC),
|
||||
), nil
|
||||
return standardEPDGHostname(identity.HomeMCC, identity.HomeMNC), nil
|
||||
}
|
||||
|
||||
func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) {
|
||||
|
||||
@@ -53,18 +53,18 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
mncLength := identity.MNCLength
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
|
||||
}
|
||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||
}
|
||||
if len(identity.IMSI) < 3+mncLength {
|
||||
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
|
||||
}
|
||||
return SIMIdentity{
|
||||
return applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||
SMSC: identity.SMSC,
|
||||
}, nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
|
||||
|
||||
@@ -125,6 +125,16 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
|
||||
},
|
||||
want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org",
|
||||
},
|
||||
{
|
||||
name: "AT&T 310280 uses carrier endpoint",
|
||||
identity: SIMIdentity{
|
||||
ICCID: "8901000000000000001",
|
||||
IMSI: "310280000000001",
|
||||
HomeMCC: "310",
|
||||
HomeMNC: "280",
|
||||
},
|
||||
want: "epdg.epc.att.net",
|
||||
},
|
||||
{
|
||||
name: "explicit endpoint",
|
||||
identity: SIMIdentity{
|
||||
|
||||
@@ -317,6 +317,14 @@ type AKAProvider interface {
|
||||
Authenticate(context.Context, SIMIdentity, AKAChallenge) (AKAResult, error)
|
||||
}
|
||||
|
||||
// PreferredAKAProvider optionally lets an AKA provider select a carrier-
|
||||
// provisioned application such as ISIM. Providers that only expose USIM keep
|
||||
// implementing AKAProvider unchanged.
|
||||
type PreferredAKAProvider interface {
|
||||
AKAProvider
|
||||
AuthenticateWithPreference(context.Context, SIMIdentity, AKAChallenge, string) (AKAResult, error)
|
||||
}
|
||||
|
||||
// RadioController owns the host/modem radio projection. EnterVoWiFiRFOff must
|
||||
// not toggle the independent pure-airplane policy; Restore must return to the
|
||||
// captured pre-transaction state.
|
||||
|
||||
+10
-4
@@ -345,11 +345,14 @@ FIRST_INSTALL=0
|
||||
INITIAL_ADMIN_PASSWORD=""
|
||||
|
||||
bootstrap_admin() {
|
||||
local candidate="${1:-$BINARY_PATH}"
|
||||
local secret result
|
||||
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
|
||||
result=$(printf '%s\n' "$secret" | "$BINARY_PATH" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||
die "Failed to initialize the administrator." "Failed to initialize the administrator."
|
||||
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||
die \
|
||||
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
|
||||
"The candidate version cannot read or migrate the existing database; the installed program was not replaced. Check database and version compatibility."
|
||||
if [ "$result" = "created" ]; then
|
||||
FIRST_INSTALL=1
|
||||
INITIAL_ADMIN_PASSWORD="$secret"
|
||||
@@ -532,9 +535,12 @@ fi
|
||||
resolve_target_version
|
||||
skip_if_equal
|
||||
download_and_verify
|
||||
install_binary
|
||||
ensure_data_dir
|
||||
bootstrap_admin
|
||||
# Validate the database with the downloaded binary before replacing the
|
||||
# installed program. In particular, a release with an older schema must never
|
||||
# overwrite a newer working binary and leave the service in a restart loop.
|
||||
bootstrap_admin "${VOCAT_TMP}/vocat"
|
||||
install_binary
|
||||
setup_env
|
||||
write_service
|
||||
enable_and_start
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh VoCat's offline PLMN name table from Android's carrier database.
|
||||
"""Refresh VoCat's offline carrier table from Android's carrier database.
|
||||
|
||||
The AOSP carrier ID table is maintained for Android's own carrier recognition.
|
||||
Only unconstrained MCC/MNC records are used here: MVNO matches that also require
|
||||
an SPN, IMSI prefix, GID or ICCID prefix must not rename the serving MNO.
|
||||
The compact ``c`` map remains the MCC/MNC-only fallback. The ``r`` list keeps
|
||||
Android's SIM-identity rules (IMSI, ICCID, SPN and GID) so travel eSIMs and
|
||||
MVNOs sharing an MNO's PLMN can be identified without hard-coded exceptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -125,10 +125,61 @@ def aosp_carriers(text: str) -> dict[str, str]:
|
||||
return carriers
|
||||
|
||||
|
||||
RULE_FIELDS = {
|
||||
"mccmnc_tuple": "m",
|
||||
"imsi_prefix_xpattern": "x",
|
||||
"spn": "s",
|
||||
"gid1": "g1",
|
||||
"gid2": "g2",
|
||||
"iccid_prefix": "i",
|
||||
}
|
||||
|
||||
|
||||
def textproto_strings(block: str, field: str) -> list[str]:
|
||||
return [
|
||||
json.loads(value)
|
||||
for value in re.findall(
|
||||
rf"^\s*{re.escape(field)}:\s*(\"(?:\\.|[^\"\\])*\")",
|
||||
block,
|
||||
re.M,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def aosp_identity_rules(text: str) -> list[dict[str, object]]:
|
||||
"""Return rules VoCat can evaluate using values read directly from a SIM.
|
||||
|
||||
Android combines different fields with AND and repeated values of one field
|
||||
with OR. Rules that also require APN, PNN/PLMN or carrier certificates are
|
||||
omitted until those inputs are available; treating an unavailable input as
|
||||
a wildcard would incorrectly identify subscriptions.
|
||||
"""
|
||||
|
||||
supported = set(RULE_FIELDS)
|
||||
rules: list[dict[str, object]] = []
|
||||
for carrier in braced_blocks(text, "carrier_id"):
|
||||
name = textproto_string(carrier, "carrier_name").strip()
|
||||
if not name:
|
||||
continue
|
||||
for attribute in braced_blocks(carrier, "carrier_attribute"):
|
||||
fields = set(re.findall(r"^\s*([a-zA-Z0-9_]+)\s*:", attribute, re.M))
|
||||
if not fields.issubset(supported) or fields == {"mccmnc_tuple"}:
|
||||
continue
|
||||
rule: dict[str, object] = {"n": name}
|
||||
for source_field, output_field in RULE_FIELDS.items():
|
||||
values = textproto_strings(attribute, source_field)
|
||||
if values:
|
||||
rule[output_field] = values
|
||||
if rule.get("m"):
|
||||
rules.append(rule)
|
||||
return rules
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with urllib.request.urlopen(SOURCE_URL, timeout=30) as response:
|
||||
source = base64.b64decode(response.read()).decode("utf-8")
|
||||
names = aosp_carriers(source)
|
||||
identity_rules = aosp_identity_rules(source)
|
||||
table = json.loads(FRONTEND_TABLE.read_text(encoding="utf-8"))
|
||||
countries: dict[str, str] = table["i"]
|
||||
countries.update({str(mcc): "us" for mcc in range(310, 317)})
|
||||
@@ -149,19 +200,23 @@ def main() -> None:
|
||||
"c": dict(sorted(carriers.items())),
|
||||
"i": dict(sorted(countries.items())),
|
||||
"t": sorted(set(table["t"])),
|
||||
"r": identity_rules,
|
||||
"meta": {
|
||||
"source": "Android Open Source Project carrier_list.textpb",
|
||||
"source_url": SOURCE_URL.removesuffix("?format=TEXT"),
|
||||
"aosp_version": version_match.group(1) if version_match else "unknown",
|
||||
"aosp_generic_records": len(names),
|
||||
"aosp_identity_rules": len(identity_rules),
|
||||
},
|
||||
}
|
||||
encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
FRONTEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
||||
BACKEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
||||
frontend_encoded = json.dumps(output, ensure_ascii=False, indent=4) + "\n"
|
||||
backend_encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
FRONTEND_TABLE.write_text(frontend_encoded, encoding="utf-8", newline="\n")
|
||||
BACKEND_TABLE.write_text(backend_encoded, encoding="utf-8", newline="\n")
|
||||
print(
|
||||
f"updated {len(carriers)} PLMN records "
|
||||
f"({len(names)} generic AOSP records, version {output['meta']['aosp_version']})"
|
||||
f"({len(names)} generic records, {len(identity_rules)} identity rules, "
|
||||
f"version {output['meta']['aosp_version']})"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+38
-2
@@ -83,7 +83,29 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
async function refreshCSRFToken(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) notifyUnauthorized();
|
||||
return false;
|
||||
}
|
||||
const payload = await response.json() as { data?: { csrf_token?: string } };
|
||||
const token = payload?.data?.csrf_token;
|
||||
if (!token) return false;
|
||||
sessionStorage.setItem(CSRF_KEY, token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: boolean): Promise<T> {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers);
|
||||
const formBody = typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||
@@ -116,7 +138,6 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
||||
: { message: await response.text() };
|
||||
const normalized = camelize<Record<string, unknown>>(payload);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) notifyUnauthorized();
|
||||
const nested = normalized.error;
|
||||
const detail = nested && typeof nested === "object"
|
||||
? {
|
||||
@@ -124,11 +145,26 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
||||
requestId: (normalized.requestId as string | undefined) || (nested as ApiErrorBody).requestId,
|
||||
}
|
||||
: normalized as ApiErrorBody;
|
||||
if (
|
||||
retryCSRF &&
|
||||
isMutation(method) &&
|
||||
response.status === 403 &&
|
||||
detail.code === "invalid_csrf"
|
||||
) {
|
||||
if (await refreshCSRFToken()) return requestAPI<T>(path, options, false);
|
||||
notifyUnauthorized();
|
||||
} else if (response.status === 401) {
|
||||
notifyUnauthorized();
|
||||
}
|
||||
throw new ApiError(response.status, detail);
|
||||
}
|
||||
return (Object.prototype.hasOwnProperty.call(normalized, "data") ? normalized.data : normalized) as T;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
return requestAPI<T>(path, options, true);
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const result = await api<LoginResponse & { user?: { username?: string } }>("/auth/login", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { SaveRegular } from "@fluentui/react-icons";
|
||||
import { ArrowSyncRegular, SaveRegular } from "@fluentui/react-icons";
|
||||
import { cx } from "../../lib/utils";
|
||||
import { Button, Input, Modal, Select, Spinner, Tag } from "../ui";
|
||||
import { isQmiControl } from "./shared";
|
||||
@@ -17,6 +17,7 @@ export interface DeviceAddDialogProps {
|
||||
addConfig: AddDeviceForm;
|
||||
addSaving: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
onSelectDevice: (d: DiscoveredDevice) => void;
|
||||
onConfigChange: (next: AddDeviceForm) => void;
|
||||
onSave: () => void;
|
||||
@@ -85,7 +86,12 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mb-3 text-sm text-gray-500">{t("选择一个“未配置”的设备,系统将自动填充 AT 端口与识别信息。")}</div>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-gray-500">{t("选择一个“未配置”的设备,系统将自动填充 AT 端口与识别信息。")}</div>
|
||||
<Button size="small" loading={props.discovering} onClick={props.onRefresh} icon={<ArrowSyncRegular />}>
|
||||
{t("刷新设备")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[260px] space-y-2 overflow-auto pr-1">
|
||||
{props.discovering ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-gray-400">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FieldRow } from "./FieldRow";
|
||||
import { useShowSensitive } from "./shared";
|
||||
import type { DeviceDetail } from "./types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { carrierBrandIso } from "../../lib/carrier";
|
||||
import { carrierIso } from "../../lib/carrier";
|
||||
import { CountryFlag } from "../CountryFlag";
|
||||
|
||||
export interface OverviewSimPanelProps {
|
||||
@@ -22,7 +22,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber
|
||||
const sensitive = !showSensitive;
|
||||
const activeEsim = (device.activeEsimProfileName || "").trim();
|
||||
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
|
||||
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
|
||||
const carrierCountryCode = String(modem?.homeCarrierCountryCode ?? "").trim() || carrierIso(modem?.imsi);
|
||||
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
|
||||
const backendLabel =
|
||||
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
|
||||
|
||||
@@ -94,7 +94,7 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
||||
<div className="space-y-1.5 border-t border-gray-100 px-3 pb-2 pt-2 text-sm text-gray-700 dark:border-white/5 dark:text-gray-200">
|
||||
{eapRejected ? (
|
||||
<div className="mb-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
{t("已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。")}
|
||||
{t("已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。")}
|
||||
</div>
|
||||
) : null}
|
||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||
|
||||
@@ -259,20 +259,18 @@ export function simOperatorDisplay(device?: DeviceDetail | null): string {
|
||||
const spn = String(modem?.nativeSpn ?? "").trim();
|
||||
const name = oplPnnName(modem) || firstPnnName(modem?.pnn);
|
||||
const plmn = plmnOf(modem);
|
||||
// EF_SPN is the SIM's customer-facing brand. Do not append the currently
|
||||
// visited PLMN: a roaming Lebara UK SIM on a Chinese network would otherwise
|
||||
// be mislabeled as "Lebara (460xx)". Append the home/authentication PLMN
|
||||
// resolved from IMSI instead, so GigSky on 222-01 renders as
|
||||
// "GigSky (22201)" even while roaming.
|
||||
if (spn) {
|
||||
const home = lookupCarrier(modem?.imsi);
|
||||
return withPlmn(spn, home ? home.mcc + home.mnc : cardPlmnOf(modem));
|
||||
}
|
||||
if (name) return withPlmn(name, plmn);
|
||||
// Home ("original") carrier resolved from the SIM's IMSI via the MCC/MNC table.
|
||||
// Readable even when the modem isn't camped (VoWiFi RF-off / flight mode).
|
||||
// "Original Carrier" means the IMSI home/authentication network. EF_SPN is
|
||||
// only a profile-supplied display brand (travel eSIMs and MVNOs may put their
|
||||
// storefront name there), so it must not override a known home PLMN.
|
||||
const resolvedName = String(modem?.homeCarrierName ?? "").trim();
|
||||
const resolvedPLMN = String(modem?.homeCarrierPlmn ?? "").trim();
|
||||
if (resolvedName) return withPlmn(resolvedName, resolvedPLMN);
|
||||
const carrier = lookupCarrier(modem?.imsi);
|
||||
if (carrier) return withPlmn(carrier.name, carrier.mcc + carrier.mnc);
|
||||
// If the bundled carrier database cannot resolve the home PLMN, retain the
|
||||
// card's own labels as graceful, data-driven fallbacks.
|
||||
if (spn) return withPlmn(spn, cardPlmnOf(modem));
|
||||
if (name) return withPlmn(name, plmn);
|
||||
if (plmn) return plmn;
|
||||
const cardPlmn = cardPlmnOf(modem);
|
||||
if (cardPlmn) return cardPlmn;
|
||||
|
||||
@@ -21,6 +21,10 @@ function profileLabel(profile: { name?: string; serviceProviderName?: string; ic
|
||||
return String(profile.name || profile.serviceProviderName || profile.iccid).trim();
|
||||
}
|
||||
|
||||
function currentDeviceICCID(device: DeviceListItem) {
|
||||
return String(device.modem?.iccid || device.vowifiRuntime?.iccid || "").trim();
|
||||
}
|
||||
|
||||
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props;
|
||||
@@ -30,7 +34,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const proxyName = proxy?.name || proxy?.id || "";
|
||||
const deviceKey = devices
|
||||
.map((device) => `${device.id}:${String(device.modem?.iccid || "").trim()}`)
|
||||
.map((device) => `${device.id}:${currentDeviceICCID(device)}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const current = useMemo(
|
||||
@@ -53,7 +57,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
let active = true;
|
||||
setLoadingProfiles(true);
|
||||
Promise.allSettled(devices.map(async (device) => {
|
||||
const currentICCID = String(device.modem?.iccid || "").trim();
|
||||
const currentICCID = currentDeviceICCID(device);
|
||||
let installed: ProfileProxyCandidate[] = [];
|
||||
try {
|
||||
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
|
||||
|
||||
@@ -101,7 +101,7 @@ export function NetworkAccessCard({
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。")}
|
||||
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。")}
|
||||
</p>
|
||||
{cidrs.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/30 py-2 text-center text-xs text-gray-400 dark:border-white/10 dark:bg-white/5">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface AutomaticTaskProfileGroup {
|
||||
aidHex?: string;
|
||||
profiles?: Array<{
|
||||
iccid: string;
|
||||
name?: string;
|
||||
serviceProviderName?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AutomaticTaskProfileOption {
|
||||
iccid: string;
|
||||
aidHex: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AutomaticTaskProfileRequestGuard {
|
||||
begin: () => number;
|
||||
invalidate: () => void;
|
||||
isCurrent: (requestID: number) => boolean;
|
||||
}
|
||||
|
||||
export function createAutomaticTaskProfileRequestGuard(): AutomaticTaskProfileRequestGuard {
|
||||
let latestRequestID = 0;
|
||||
return {
|
||||
begin: () => ++latestRequestID,
|
||||
invalidate: () => { latestRequestID += 1; },
|
||||
isCurrent: (requestID) => requestID === latestRequestID,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutomaticTaskProfileOptions(
|
||||
groups: AutomaticTaskProfileGroup[],
|
||||
currentICCID: string,
|
||||
currentSIMLabel: string,
|
||||
): AutomaticTaskProfileOption[] {
|
||||
const options = groups.flatMap((group, groupIndex) =>
|
||||
(group.profiles || []).map((profile) => ({
|
||||
iccid: profile.iccid,
|
||||
aidHex: group.aidHex || "",
|
||||
label: `${profile.name || profile.serviceProviderName || `Profile ${groupIndex + 1}`} · ${profile.iccid}`,
|
||||
})),
|
||||
);
|
||||
const iccid = currentICCID.trim();
|
||||
if (iccid && !options.some((option) => option.iccid.trim() === iccid)) {
|
||||
options.push({ iccid, aidHex: "", label: `${currentSIMLabel} · ${iccid}` });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function selectAutomaticTaskProfileOption(
|
||||
options: AutomaticTaskProfileOption[],
|
||||
requestedICCID: string,
|
||||
): AutomaticTaskProfileOption | undefined {
|
||||
const iccid = requestedICCID.trim();
|
||||
if (iccid) return options.find((option) => option.iccid.trim() === iccid);
|
||||
return options[0];
|
||||
}
|
||||
@@ -47,13 +47,3 @@ export function carrierIso(imsi?: string): string {
|
||||
if (hit) return hit.iso;
|
||||
return data.i[imsiDigits(imsi).slice(0, 3)] ?? "";
|
||||
}
|
||||
|
||||
// carrierBrandIso keeps the normal IMSI country flag for branded/MVNO SIMs.
|
||||
// Lebara UK's Vodafone-NL-hosted 204-04 eSIM is the one known exception: its
|
||||
// customer-facing country is GB even though AKA must continue using 204-04.
|
||||
export function carrierBrandIso(spn?: string, imsi?: string): string {
|
||||
const brand = String(spn ?? "").trim().toLowerCase();
|
||||
const digits = imsiDigits(imsi);
|
||||
if (brand.includes("lebara") && digits.startsWith("20404")) return "gb";
|
||||
return carrierIso(imsi);
|
||||
}
|
||||
|
||||
+11
-4
@@ -178,8 +178,11 @@ export const EN_DICT: Record<string, string> = {
|
||||
自动任务: "Automatic Tasks",
|
||||
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Switch to a selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
|
||||
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Switch to a selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue",
|
||||
"按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Use the selected SIM or switch to the selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
|
||||
"按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Use the selected SIM or switch to the selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue",
|
||||
添加任务: "Add Task",
|
||||
"设备 / Profile": "Device / Profile",
|
||||
"设备 / SIM / Profile": "Device / SIM / Profile",
|
||||
执行环境: "Environment",
|
||||
周期: "Schedule",
|
||||
下次执行: "Next Run",
|
||||
@@ -194,6 +197,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
立即执行: "Run Now",
|
||||
暂无自动任务: "No automatic tasks",
|
||||
"添加任务后,系统会按设备排队并在执行前校验目标 Profile": "Tasks are queued per device and the target profile is verified before execution",
|
||||
"添加任务后,系统会按设备排队并在执行前校验目标 SIM / Profile": "Tasks are queued per device and the target SIM or profile is verified before execution",
|
||||
最近执行记录: "Recent Runs",
|
||||
排队时间: "Queued At",
|
||||
尝试次数: "Attempts",
|
||||
@@ -242,6 +246,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
请输入号码: "Enter a number",
|
||||
"请选择 eSIM Profile": "Select an eSIM profile",
|
||||
"请选择 Profile": "Select a profile",
|
||||
"请选择 SIM 卡或 eSIM Profile": "Select a SIM card or eSIM profile",
|
||||
"请选择 SIM / Profile": "Select a SIM or profile",
|
||||
请选择设备: "Select a device",
|
||||
"确定删除这个自动任务吗?": "Delete this automatic task?",
|
||||
任务: "Task",
|
||||
@@ -352,8 +358,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"Opening to the public internet greatly expands the attack surface. Use a strong password and switch back to Internal Only as soon as possible.",
|
||||
额外放行网段: "Additional Allowed Ranges",
|
||||
添加网段: "Add Range",
|
||||
"在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。":
|
||||
"CIDRs or single IPs always allowed in addition to the built-in internal ranges (e.g. 203.0.113.0/24).",
|
||||
"在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。":
|
||||
"CIDRs or single IPs always allowed in addition to the built-in internal ranges; notification delivery may also access these destinations (e.g. 198.18.0.0/15).",
|
||||
暂无额外放行网段: "No additional allowed ranges",
|
||||
信任代理请求头: "Trust Proxy Headers",
|
||||
"仅在系统位于可信反向代理之后时开启,按 X-Forwarded-For 判定来源;否则客户端可伪造该头绕过内网限制。":
|
||||
@@ -753,8 +759,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"扫描中...": "Scanning...",
|
||||
"扫描可用网络": "Scan Available Networks",
|
||||
"不可注册": "Unavailable",
|
||||
"已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。":
|
||||
"The carrier ePDG was reached, but it rejected EAP-AKA authentication for this SIM. The profile usually has no IMS/Wi-Fi Calling entitlement; reconnecting will not fix it, so use a carrier profile that supports VoWiFi.",
|
||||
"已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。":
|
||||
"The carrier ePDG was reached, but the EAP-AKA flow was rejected. This can be caused by the initial identity, carrier IKE/EAP interoperability, or subscription policy; check the error details for the failing stage.",
|
||||
"扫描结果只代表模组在当前位置实际收到的运营商信号,不代表模组支持的全部运营商;禁用网络表示当前 SIM 不允许注册。":
|
||||
"Scan results show only networks the modem can currently receive, not every operator the hardware supports. A forbidden network cannot be used by the current SIM.",
|
||||
"扫描网络失败": "Network scan failed",
|
||||
@@ -993,6 +999,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
最新: "Latest",
|
||||
加载更多: "Load More",
|
||||
刷新: "Refresh",
|
||||
"刷新设备": "Refresh Devices",
|
||||
|
||||
// ---- 代理 / 国家规则(i18n 补充) ----
|
||||
"绑定:": "Bound:",
|
||||
|
||||
+5247
-2
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,11 @@ import {
|
||||
message,
|
||||
} from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import {
|
||||
buildAutomaticTaskProfileOptions,
|
||||
createAutomaticTaskProfileRequestGuard,
|
||||
selectAutomaticTaskProfileOption,
|
||||
} from "../lib/automaticTaskProfiles";
|
||||
|
||||
type TaskType = "sms" | "call" | "public_ip";
|
||||
type TaskEnvironment = "vowifi" | "cellular";
|
||||
@@ -130,6 +135,10 @@ function formatDateTime(value?: string) {
|
||||
return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString();
|
||||
}
|
||||
|
||||
function currentDeviceICCID(device?: DeviceListItem) {
|
||||
return String(device?.modem?.iccid || device?.vowifiRuntime?.iccid || "").trim();
|
||||
}
|
||||
|
||||
const fieldLabel = "mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-200";
|
||||
|
||||
export default function AutomaticTasksPage() {
|
||||
@@ -152,6 +161,7 @@ export default function AutomaticTasksPage() {
|
||||
// is actually looking at instead of snapping back to page 1 on every tick.
|
||||
const runsPageRef = useRef(1);
|
||||
const runsPageSizeRef = useRef(20);
|
||||
const profileRequestGuardRef = useRef(createAutomaticTaskProfileRequestGuard());
|
||||
|
||||
const load = useCallback(async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
@@ -209,6 +219,8 @@ export default function AutomaticTasksPage() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, reloadRuns]);
|
||||
|
||||
useEffect(() => () => profileRequestGuardRef.current.invalidate(), []);
|
||||
|
||||
function changeRunsPage(page: number) {
|
||||
runsPageRef.current = page;
|
||||
setRunsPage(page);
|
||||
@@ -223,37 +235,53 @@ export default function AutomaticTasksPage() {
|
||||
void fetchRuns(1, pageSize);
|
||||
}
|
||||
|
||||
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => {
|
||||
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "", currentICCID = "") => {
|
||||
if (!deviceId) {
|
||||
profileRequestGuardRef.current.invalidate();
|
||||
setProfiles([]);
|
||||
setProfileLoading(false);
|
||||
return;
|
||||
}
|
||||
const requestID = profileRequestGuardRef.current.begin();
|
||||
setProfiles([]);
|
||||
if (!deviceId) return;
|
||||
setProfileLoading(true);
|
||||
let groups: EsimProfileGroup[] = [];
|
||||
let inventoryError: unknown;
|
||||
try {
|
||||
const data = await api<{ profiles?: EsimProfileGroup[] }>(`/devices/${encodeURIComponent(deviceId)}/esim`);
|
||||
const options = (data.profiles || []).flatMap((group, groupIndex) =>
|
||||
(group.profiles || []).map((profile) => ({
|
||||
iccid: profile.iccid,
|
||||
aidHex: group.aidHex || "",
|
||||
label: `${profile.name || profile.serviceProviderName || `Profile ${groupIndex + 1}`} · ${profile.iccid}`,
|
||||
})),
|
||||
);
|
||||
setProfiles(options);
|
||||
setForm((current) => {
|
||||
if (current.deviceId !== deviceId) return current;
|
||||
const selected = options.find((item) => item.iccid === (keepICCID || current.profileIccid)) || options[0];
|
||||
return selected ? { ...current, profileIccid: selected.iccid, profileAid: selected.aidHex } : current;
|
||||
});
|
||||
groups = data.profiles || [];
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
inventoryError = error;
|
||||
}
|
||||
}, []);
|
||||
if (!profileRequestGuardRef.current.isCurrent(requestID)) return;
|
||||
const options = buildAutomaticTaskProfileOptions(groups, currentICCID, t("当前 SIM 卡"));
|
||||
const requestedICCID = keepICCID.trim();
|
||||
const requestedUnavailable = requestedICCID !== "" &&
|
||||
!options.some((option) => option.iccid.trim() === requestedICCID);
|
||||
if (inventoryError && (options.length === 0 || requestedUnavailable)) {
|
||||
message.error(apiMessage(inventoryError));
|
||||
}
|
||||
setProfiles(options);
|
||||
setForm((current) => {
|
||||
if (current.deviceId !== deviceId) return current;
|
||||
const selected = selectAutomaticTaskProfileOption(options, requestedICCID);
|
||||
return selected ? { ...current, profileIccid: selected.iccid, profileAid: selected.aidHex } : current;
|
||||
});
|
||||
setProfileLoading(false);
|
||||
}, [t]);
|
||||
|
||||
function closeEditor() {
|
||||
profileRequestGuardRef.current.invalidate();
|
||||
setProfileLoading(false);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const deviceByID = useMemo(() => new Map(devices.map((device) => [device.id, device])), [devices]);
|
||||
const taskByID = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
|
||||
function edit(task?: AutomaticTask) {
|
||||
const deviceId = task?.deviceId || devices[0]?.id || "";
|
||||
const selectedDevice = devices.find((device) => device.id === deviceId);
|
||||
let next = task ? {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
@@ -272,7 +300,7 @@ export default function AutomaticTasksPage() {
|
||||
message: task.payload?.message || "",
|
||||
durationSeconds: task.payload?.durationSeconds || 30,
|
||||
} : emptyForm(deviceId);
|
||||
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
|
||||
if (selectedDevice?.deviceType === "usb_sim_reader") {
|
||||
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
|
||||
}
|
||||
if (!advancedTasksAvailable && (next.taskType === "public_ip" || next.environment === "cellular")) {
|
||||
@@ -280,17 +308,18 @@ export default function AutomaticTasksPage() {
|
||||
}
|
||||
setForm(next);
|
||||
setOpen(true);
|
||||
void loadProfiles(deviceId, next.profileIccid);
|
||||
void loadProfiles(deviceId, next.profileIccid, currentDeviceICCID(selectedDevice));
|
||||
}
|
||||
|
||||
function chooseDevice(deviceId: string) {
|
||||
const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader";
|
||||
const selectedDevice = devices.find((device) => device.id === deviceId);
|
||||
const reader = selectedDevice?.deviceType === "usb_sim_reader";
|
||||
setForm((current) => ({
|
||||
...current, deviceId, profileIccid: "", profileAid: "",
|
||||
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
|
||||
environment: reader || !advancedTasksAvailable ? "vowifi" : current.environment,
|
||||
}));
|
||||
void loadProfiles(deviceId);
|
||||
void loadProfiles(deviceId, "", currentDeviceICCID(selectedDevice));
|
||||
}
|
||||
|
||||
function chooseProfile(iccid: string) {
|
||||
@@ -310,7 +339,7 @@ export default function AutomaticTasksPage() {
|
||||
async function save() {
|
||||
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
|
||||
if (!form.deviceId) return message.warning(t("请选择设备"));
|
||||
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
|
||||
if (!form.profileIccid) return message.warning(t("请选择 SIM 卡或 eSIM Profile"));
|
||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
|
||||
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
|
||||
}
|
||||
@@ -344,7 +373,7 @@ export default function AutomaticTasksPage() {
|
||||
body,
|
||||
});
|
||||
message.success(t(form.id ? "自动任务已更新" : "自动任务已创建"));
|
||||
setOpen(false);
|
||||
closeEditor();
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
@@ -411,8 +440,8 @@ export default function AutomaticTasksPage() {
|
||||
<PageHeader
|
||||
title={t("自动任务")}
|
||||
subtitle={advancedTasksAvailable
|
||||
? t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
|
||||
: t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
|
||||
? t("按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
|
||||
: t("按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
|
||||
/>
|
||||
|
||||
@@ -422,7 +451,7 @@ export default function AutomaticTasksPage() {
|
||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("任务")}</th>
|
||||
<th className="px-4 py-3">{t("设备 / Profile")}</th>
|
||||
<th className="px-4 py-3">{t("设备 / SIM / Profile")}</th>
|
||||
<th className="px-4 py-3">{t("类型")}</th>
|
||||
<th className="px-4 py-3">{t("执行环境")}</th>
|
||||
<th className="px-4 py-3">{t("周期")}</th>
|
||||
@@ -466,7 +495,7 @@ export default function AutomaticTasksPage() {
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<SendClockRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无自动任务")}</div>
|
||||
<div className="mt-1 text-xs">{t("添加任务后,系统会按设备排队并在执行前校验目标 Profile")}</div>
|
||||
<div className="mt-1 text-xs">{t("添加任务后,系统会按设备排队并在执行前校验目标 SIM / Profile")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
@@ -498,11 +527,11 @@ export default function AutomaticTasksPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
|
||||
<Modal open={open} onClose={closeEditor} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
|
||||
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("SIM / Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 SIM / Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={taskTypeOptions} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
|
||||
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
|
||||
@@ -519,7 +548,7 @@ export default function AutomaticTasksPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("启用任务")}</div><div className="text-xs text-gray-400">{t("停用后不会进入执行队列")}</div></div><Switch checked={form.enabled} onChange={(enabled) => setForm({ ...form, enabled })} /></div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("完成后推送通知")}</div><div className="text-xs text-gray-400">{t("发送到全部已配置并启用的通知渠道")}</div></div><Switch checked={form.notify} onChange={(notify) => setForm({ ...form, notify })} /></div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2"><Button onClick={() => setOpen(false)}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></div>
|
||||
<div className="mt-5 flex justify-end gap-2"><Button onClick={closeEditor}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -136,11 +136,17 @@ export default function DevicesPage() {
|
||||
|
||||
const loadDiscovered = useCallback(async () => {
|
||||
setDiscovering(true);
|
||||
// Never leave a previous physical scan visible while a new scan is in
|
||||
// progress or after it fails.
|
||||
setDiscovered([]);
|
||||
setAddSelected(null);
|
||||
setAddConfig(EMPTY_ADD);
|
||||
try {
|
||||
const res = await api<{ devices?: DiscoveredDevice[] }>("/devices/discovered?with_imei=1");
|
||||
setDiscovered(Array.isArray(res?.devices) ? res!.devices! : []);
|
||||
const devices = Array.isArray(res?.devices) ? res!.devices! : [];
|
||||
setDiscovered(devices);
|
||||
} catch {
|
||||
/* ignore */
|
||||
setDiscovered([]);
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
@@ -296,13 +302,13 @@ export default function DevicesPage() {
|
||||
try {
|
||||
await api("/devices/actions/rescan", { method: "POST" });
|
||||
message.success(t("设备重新扫描完成"));
|
||||
await loadDevices(true);
|
||||
await Promise.all([loadDevices(true), loadDiscovered()]);
|
||||
} catch (e) {
|
||||
message.error(apiMessage(e) || t("重新扫描失败"));
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
}, [loadDevices]);
|
||||
}, [loadDevices, loadDiscovered]);
|
||||
|
||||
const handleOpenSms = useCallback(() => {
|
||||
const id = selectedIdRef.current;
|
||||
@@ -736,6 +742,7 @@ export default function DevicesPage() {
|
||||
addConfig={addConfig}
|
||||
addSaving={addSaving}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onRefresh={() => void loadDiscovered()}
|
||||
onSelectDevice={selectDiscovered}
|
||||
onConfigChange={setAddConfig}
|
||||
onSave={saveAdd}
|
||||
|
||||
@@ -64,6 +64,9 @@ export interface ModemSummary {
|
||||
cardMcc?: string;
|
||||
cardMnc?: string;
|
||||
cardCountry?: string;
|
||||
homeCarrierName?: string;
|
||||
homeCarrierPlmn?: string;
|
||||
homeCarrierCountryCode?: string;
|
||||
serviceBlocked?: boolean;
|
||||
blockedReason?: string;
|
||||
networkMode: string;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const source = await readFile(new URL("../src/lib/automaticTaskProfiles.ts", import.meta.url), "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ES2022,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
});
|
||||
const moduleURL = `data:text/javascript;base64,${Buffer.from(compiled.outputText).toString("base64")}`;
|
||||
const {
|
||||
buildAutomaticTaskProfileOptions,
|
||||
createAutomaticTaskProfileRequestGuard,
|
||||
selectAutomaticTaskProfileOption,
|
||||
} = await import(moduleURL);
|
||||
|
||||
test("uses the current physical SIM when the device has no eSIM profiles", () => {
|
||||
const iccid = "89441000400128014257";
|
||||
|
||||
assert.deepEqual(buildAutomaticTaskProfileOptions([], iccid, "Current SIM"), [
|
||||
{
|
||||
iccid,
|
||||
aidHex: "",
|
||||
label: `Current SIM · ${iccid}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
|
||||
const iccid = "89441000400128014257";
|
||||
|
||||
assert.deepEqual(
|
||||
buildAutomaticTaskProfileOptions(
|
||||
[{ aidHex: "a0000005591010ffffffff8900000100", profiles: [{ iccid, name: "Travel" }] }],
|
||||
iccid,
|
||||
"Current SIM",
|
||||
),
|
||||
[
|
||||
{
|
||||
iccid,
|
||||
aidHex: "a0000005591010ffffffff8900000100",
|
||||
label: `Travel · ${iccid}`,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not replace a saved profile when a failed inventory only exposes the current SIM", () => {
|
||||
const currentICCID = "89441000400128014257";
|
||||
const savedICCID = "89104100000028106378";
|
||||
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
||||
|
||||
assert.equal(selectAutomaticTaskProfileOption(options, savedICCID), undefined);
|
||||
});
|
||||
|
||||
test("accepts state updates only from the latest profile request", () => {
|
||||
const guard = createAutomaticTaskProfileRequestGuard();
|
||||
const first = guard.begin();
|
||||
const second = guard.begin();
|
||||
|
||||
assert.equal(guard.isCurrent(first), false);
|
||||
assert.equal(guard.isCurrent(second), true);
|
||||
guard.invalidate();
|
||||
assert.equal(guard.isCurrent(second), false);
|
||||
});
|
||||
Reference in New Issue
Block a user