63 Commits
Author SHA1 Message Date
MengMengCode 2d8552b670 feat: reset admin credentials without requiring current password and update related prompts 2026-08-14 20:34:30 +08:00
Meng MengandGitHub dbc6db4f08 Merge pull request #16 from CwithW/fix-dockerfile-missing-iproute2
fix: install iproute2 in runtime image
2026-08-14 20:00:40 +08:00
Chara White 57539df603 fix: install iproute2 in runtime image 2026-08-14 19:50:01 +08:00
MengMengCode ecce22eacf feat: add notification destination context for Telegram polling and implement related test 2026-08-14 19:09:31 +08:00
MengMengCode d37a191011 feat: add home carrier information to ModemSummary interface 2026-08-14 00:22:39 +08:00
MengMengCode ba28c7f79a Rollback 2026-08-13 23:38:45 +08:00
MengMengCode 773b44aad6 Implement 3GPP Device Identity Handling in IKE
- Added support for DEVICE_IDENTITY request as per TS 24.302.
- Implemented deviceIdentityRequested function to check for DEVICE_IDENTITY notifications.
- Created deviceIdentityNotify function to construct DEVICE_IDENTITY notifications with IMEI/IMEISV.
- Enhanced security proposal to accommodate O2 Germany's integrity-only ESP profile.
- Updated tests to validate DEVICE_IDENTITY encoding and request handling.
- Refactored security proposal to include fallback encryption and integrity algorithms.
- Improved session management to ensure proper cleanup of IPSec resources after caller deadline.
- Added comprehensive tests for security agreement parsing and algorithm support.

FIX #11
2026-08-13 23:30:00 +08:00
MengMengCode 2917378da0 fix: support O2 Germany certificate-authenticated EAP 2026-08-13 21:28:38 +08:00
MengMengCode 69b1c3eec6 fix: stabilize EM7430 hotplug support 2026-08-13 20:58:24 +08:00
MengMengCode 01e25ff12c feat: support Sierra EM7430 MBIM modems 2026-08-13 20:05:07 +08:00
MengMengCode bed8ac9fdf fix: bind physical SIMs and validate multi-arch images 2026-08-13 11:01:36 +08:00
MengMengCode 1100f20dc5 fix: discover USB smart card readers without pcscd 2026-08-13 10:53:56 +08:00
MengMengCode c9656ea3fa fix: persist admin credentials in database and discover MHI modems 2026-08-13 10:46:12 +08:00
Meng MengandGitHub cb44348a76 Merge pull request #7 from jiuliking/fix/plmn-23410-ims-interoperability
fix: support PLMN 234-10 IMS registration
2026-08-13 10:17:10 +08:00
Alex 794dd1177e [verified] fix: support PLMN 234-10 IMS registration
Select UDP P-CSCF signalling for the explicit 234-10 and 234-010 PLMN forms while preserving exact MNC length semantics. Skip incomplete ipsec-3gpp algorithm advertisements so a later complete Security-Server offer can be selected, while keeping malformed and partially specified SA parameters fail-closed.
2026-08-13 01:45:16 +08:00
MengMengCode 400f08c6c7 Enforce hard limits and hide unavailable task paths 2026-08-12 17:24:39 +08:00
MengMengCode 1380ffb419 Update Readme.md 2026-08-12 17:09:06 +08:00
MengMengCode 45b15b245f Update Readme.md 2026-08-12 16:57:16 +08:00
MengMengCode 2780dd96de Make EC20 eSIM channels coexist with VoWiFi 2026-08-12 16:56:30 +08:00
MengMengCode 2922d6a275 Wait for OpenWrt service shutdown during upgrades 2026-08-12 16:36:04 +08:00
MengMengCode 288e856fdb Stabilize OpenWrt modem startup and upgrades 2026-08-12 16:30:22 +08:00
MengMengCode f17d925c4c Fix static ARM builds and OpenWrt VoWiFi setup 2026-08-12 15:58:02 +08:00
Meng MengandGitHub 296f963885 Merge pull request #3 from MengMengCode/dependabot/go_modules/go_modules-a3c8a40308
build(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in the go_modules group across 1 directory
2026-08-12 02:38:34 +08:00
dependabot[bot]andGitHub 1b9546a73d build(deps): bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.41.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-11 18:35:30 +00:00
MengMengCode 22487dbb1f Add PC/SC backend support for USB SIM readers
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms.
- Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading.
- Added methods for reading identity, checking readiness, and authenticating with USIM applications.
- Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses.
- Defined necessary types and error handling in `types.go` for better clarity and usability.
- Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities.
- Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`.
- Implemented tests to ensure correct functionality and error handling in various scenarios.
2026-08-12 02:33:32 +08:00
Meng MengandGitHub f9bb38aabe Add star history badge to README 2026-08-11 23:52:46 +08:00
MengMengCode 5bb5808706 fix: update expected local time format in WeCom SMS values test 2026-08-11 23:06:48 +08:00
Meng MengandGitHub d70937cc47 Merge pull request #2 from zAhYAng/feat/wecom-notifications
feat: add WeCom message push notifications
2026-08-11 22:56:19 +08:00
zAhYAng eab658dc90 fix: polish WeCom notification settings 2026-08-11 22:28:36 +08:00
MengMengCode 0d738d4ce4 feat: add custom phone number support to card policies
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema.
- Updated the API to handle custom phone number input, including validation and normalization.
- Modified the CardPolicyPanel component to allow users to set and save a custom phone number.
- Enhanced the settings API to include the custom phone number in responses and updates.
- Added tests to ensure the correct functionality of custom phone number handling.
- Removed hardcoded environment variable for VOCAT_ADDR in service files.
2026-08-11 21:58:28 +08:00
zAhYAng 962c58fdd1 feat: add WeCom notification settings 2026-08-11 21:41:43 +08:00
zAhYAng 7b2e005b37 feat: dispatch WeCom notifications 2026-08-11 21:39:53 +08:00
zAhYAng f1e70ecee5 feat: configure WeCom notifications 2026-08-11 21:38:09 +08:00
zAhYAng f012c556e9 feat: add WeCom payload renderer 2026-08-11 21:33:38 +08:00
zAhYAng ab8bbbc1ed docs: plan WeCom notifications and worktree setup 2026-08-11 21:19:02 +08:00
MengMengCode 609a591045 feat: enhance email validation to prevent injection attacks and improve related tests 2026-08-11 19:41:06 +08:00
MengMengCode 461054615b feat: implement automatic task recovery logic and enhance related tests 2026-08-11 19:32:22 +08:00
MengMengCode 020fb619a9 feat: enhance security by preventing exposure of sensitive credentials in logs and errors 2026-08-11 19:07:58 +08:00
MengMengCode 3cc73f1885 feat: implement eSIM notification handling and email message construction
- Add eSIM notification management in `esim_notifications.go` with functions to retrieve, list, and remove notifications.
- Implement parsing logic for pending notifications and notification metadata.
- Create tests for eSIM notification parsing and request handling in `esim_notifications_test.go`.
- Introduce email message construction in `email_message.go` to securely format and send emails.
- Add tests for email message encoding and validation in `email_message_test.go`.
- Enhance the CardPolicyAPN component to manage APN configurations, including adding, editing, and deleting custom APNs.
- Implement UI for displaying and managing APN settings with appropriate validation and user feedback.
2026-08-11 18:58:32 +08:00
MengMengCode 48fc4c5ab5 support offline flag 2026-08-11 15:59:47 +08:00
MengMengCode 707ca3c124 feat: enhance URL validation and email address parsing; refactor related components 2026-08-11 02:02:55 +08:00
MengMengCode a09f9af646 feat: update schema version and refactor proxy binding logic
- Increment schema version from 11 to 12.
- Modify ProxyResolver to use ICCID for device proxy binding resolution.
- Update tests to reflect changes in proxy binding logic using ICCID.
- Enhance DeviceBindingsDialog to manage eSIM profile bindings instead of device bindings.
- Update UI components and translations to reflect the new profile binding terminology.
- Implement pagination for automatic task runs in AutomaticTasksPage.
- Create a new Pagination component for better navigation in lists.
2026-08-11 01:23:04 +08:00
MengMengCode 928ba7746e update 2026-08-11 00:22:54 +08:00
MengMengCode 21f210d219 update readme 2026-08-10 23:05:41 +08:00
MengMengCode 8a260e86f1 Implement automatic task management with CRUD operations and UI integration
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks.
- Created `automatic_tasks_test.go` to validate task claiming and deletion behavior.
- Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution.
- Integrated device and eSIM profile selection for task configuration.
- Implemented automatic task scheduling and retry logic in the backend.
2026-08-10 22:15:54 +08:00
MengMengCode 5b8d1a86e8 feat: enhance Telegram call handling with VoWiFi and cellular support 2026-08-10 06:07:56 +08:00
MengMengCode 4df0ae0c7d feat: expand device networking and management 2026-08-10 03:27:43 +08:00
MengMengCode d8828ff26a fix: expose IMS call failures and allow unlimited calls 2026-08-09 21:52:25 +08:00
MengMengCode 337aa3c0ab fix: route calls only through ready IMS sessions 2026-08-09 21:31:24 +08:00
MengMengCode 97ca84bbfc fix: redact Telegram tokens from errors 2026-08-09 21:14:07 +08:00
MengMengCode cc477571ac feat: add Telegram AT and USSD commands 2026-08-09 21:06:35 +08:00
MengMengCode c19156e46a fix: redirect expired sessions to login 2026-08-09 20:47:53 +08:00
MengMengCode 0e68dc6893 fix: revoke sessions after updates 2026-08-09 20:23:00 +08:00
MengMengCode 1fc6ea9b6c fix: make web updates restart cleanly 2026-08-09 20:13:50 +08:00
MengMengCode 85f8790e1e fix: allow verified in-place updates 2026-08-09 20:09:44 +08:00
MengMengCode 3d117749b2 build: speed up multi-arch Docker builds 2026-08-09 19:59:05 +08:00
MengMengCode 3604319faa feat: update SMS handling to include readyForClose channel for better session management 2026-08-09 19:35:00 +08:00
MengMengCode 9fc3f1c5b8 feat: add Telegram API URL handling and version comparison utilities
- Implemented `telegramAPIURL` and `validateTelegramAPIURL` functions for constructing and validating Telegram API URLs.
- Added semantic versioning utilities in `version.go` to compare versions and validate semantic version formats.
- Created tests for version comparison logic in `version_test.go`.
- Introduced IMS call handling in `call_runtime.go`, including methods for dialing, answering, and hanging up calls.
- Added tests for incoming call handling and validation in `call_runtime_test.go`.
- Developed a new `PluginsCard` component for managing plugins via URL or file upload in the web interface.
- Implemented plugin management functions in `extensions.ts` and created an `ExtensionPage` for displaying plugin contributions.
2026-08-09 19:28:12 +08:00
MengMengCode 93b0cf718c Merge branch 'master' of https://github.com/MengMengCode/VoCat 2026-08-09 16:59:32 +08:00
Meng MengandGitHub 03c8a2ceae Update README.md 2026-08-09 16:38:56 +08:00
Meng MengandGitHub d7a9fc9774 Add group and channel information
Added group and channel links to README.
2026-08-09 16:38:05 +08:00
Meng MengandGitHub 147721f237 Update thanks section in README.md 2026-08-09 16:25:14 +08:00
Meng MengandGitHub e24be6ef29 Add 'Thanks' section to README
Added a 'Thanks' section with community links.
2026-08-09 16:21:18 +08:00
65 changed files with 228 additions and 4117 deletions
-75
View File
@@ -1,75 +0,0 @@
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
+40 -242
View File
@@ -2,18 +2,11 @@ name: Pull request size limit
on:
pull_request_target:
branches:
- master
types:
- opened
- synchronize
- reopened
- ready_for_review
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 }}
@@ -21,262 +14,67 @@ concurrency:
jobs:
enforce-size-limit:
# 保持这个名字不变,这样你 Ruleset 里的 Required Check 不需要修改
name: Enforce 5,000-line limit
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 2
env:
MAX_CHANGED_LINES: "5000"
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout trusted base repository
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Check conflicts and pull request size
- name: Reject oversized pull request
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 \
api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"
response="$({
curl --fail-with-body --silent --show-error \
--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
"${api_url}"
})"
additions="$(jq -r '.additions' <<<"${response}")"
deletions="$(jq -r '.deletions' <<<"${response}")"
if [[ ! "${additions}" =~ ^[0-9]+$ || ! "${deletions}" =~ ^[0-9]+$ ]]; then
echo "Unable to read pull request line statistics." >&2
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
############################################################
changed_lines=$((additions + deletions))
{
echo "### Pull request policy"
echo "### Pull request size"
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."
echo "- Additions: ${additions}"
echo "- Deletions: ${deletions}"
echo "- Total changed lines: ${changed_lines}"
echo "- Limit: ${MAX_CHANGED_LINES}"
} >>"${GITHUB_STEP_SUMMARY}"
if (( changed_lines <= MAX_CHANGED_LINES )); then
echo "Pull request is within the ${MAX_CHANGED_LINES}-line limit."
exit 0
fi
############################################################
# PASS
############################################################
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" \
"${api_url}" \
--data '{"state":"closed"}' >/dev/null
echo
echo "Pull request passed all policy checks."
echo "No merge conflicts."
echo "Changed lines: ${CHANGED_LINES}/${MAX_CHANGED_LINES}."
message="This pull request changes ${changed_lines} lines (${additions} additions + ${deletions} deletions), exceeding the repository limit of ${MAX_CHANGED_LINES} changed lines. It has been closed automatically. Please split the changes into smaller pull requests."
comment_payload="$(jq -nc --arg body "${message}" '{body: $body}')"
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
echo "### Result"
echo
echo "✅ Pull request passed."
} >> "${GITHUB_STEP_SUMMARY}"
echo "::error::Pull request changes ${changed_lines} lines; the maximum is ${MAX_CHANGED_LINES}."
exit 1
-1
View File
@@ -10,7 +10,6 @@
*.dll
*.so
*.dylib
/fix
# ---- Cookie / secret files (NEVER commit) ----
vc.jar
-21
View File
@@ -1,21 +0,0 @@
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.
-10
View File
@@ -1,10 +0,0 @@
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
+3 -3
View File
@@ -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(os.Stdin)
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
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 password == "" {
return errors.New("bootstrap password cannot be empty")
if len(password) < 12 || len(password) > 1024 {
return errors.New("bootstrap password must contain between 12 and 1024 characters")
}
adminUsername := strings.TrimSpace(*username)
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
-31
View File
@@ -1,31 +0,0 @@
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")
}
}
+1 -11
View File
@@ -27,7 +27,6 @@ import (
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/modem"
"vocat/internal/pcsc"
"vocat/internal/server"
"vocat/internal/store"
@@ -837,9 +836,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"
@@ -881,15 +880,6 @@ 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.
-21
View File
@@ -217,24 +217,3 @@ 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)
}
}
+6 -6
View File
@@ -198,7 +198,8 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
database, err := store.Open(ctx, cfg.DatabasePath)
if err != nil {
@@ -214,6 +215,7 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
if err != nil {
return fmt.Errorf("%w: %v", errMenuStore, err)
}
fmt.Print(m.newUsername(admin.Username))
username, err := reader.ReadString('\n')
if err != nil {
@@ -523,7 +525,6 @@ 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
@@ -595,7 +596,7 @@ func (m *menu) msg(key string) string {
"prompt": {"请选择: ", "Select: "},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
"new_username": {"新用户名(直接回车保留 %s: ", "New username (Enter to keep %s): "},
"new_pw": {"新密码: ", "New password: "},
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
@@ -687,11 +688,10 @@ 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: " + detail
return "Update failed."
}
return "更新失败: " + detail
return "更新失败。"
case errors.Is(err, errMenuConfig):
if m.lang == "en" {
return "Failed to load configuration."
-3
View File
@@ -4,7 +4,6 @@ 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
@@ -19,8 +18,6 @@ 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
-14
View File
@@ -2,7 +2,6 @@ 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=
@@ -11,25 +10,16 @@ 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=
@@ -47,10 +37,6 @@ 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=
+12 -48
View File
@@ -1,7 +1,6 @@
package auth
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
@@ -21,13 +20,8 @@ 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
@@ -91,14 +85,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
current, err := s.store.CurrentAdmin(ctx)
if err == nil &&
current.Username == username &&
comparePassword(current.PasswordHash, password) == nil {
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil {
return nil
}
if err != nil && !errors.Is(err, store.ErrNotFound) {
return fmt.Errorf("auth: read configured admin: %w", err)
}
passwordHash, err := hashPassword(password, s.bcryptCost)
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
if err != nil {
return fmt.Errorf("auth: hash admin password: %w", err)
}
@@ -133,8 +127,8 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
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 len(password) < 12 || len(password) > 1024 {
return errors.New("administrator password must contain between 12 and 1024 characters")
}
if err := s.EnsureAdmin(ctx, username, password); err != nil {
return fmt.Errorf("auth: reset administrator credentials: %w", err)
@@ -145,13 +139,13 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
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) {
_ = comparePassword(s.dummyHash, password)
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
return Credentials{}, ErrInvalidCredentials
}
if err != nil {
return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
}
if comparePassword(admin.PasswordHash, password) != nil {
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil {
return Credentials{}, ErrInvalidCredentials
}
@@ -290,24 +284,24 @@ func (s *Service) ChangePassword(
currentPassword string,
newPassword string,
) error {
if newPassword == "" {
return ErrEmptyPassword
if len(newPassword) < 12 || len(newPassword) > 1024 {
return errors.New("new password must contain between 12 and 1024 characters")
}
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) {
_ = comparePassword(s.dummyHash, currentPassword)
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword))
return ErrInvalidCredentials
}
if err != nil {
return fmt.Errorf("auth: find admin: %w", err)
}
if comparePassword(admin.PasswordHash, currentPassword) != nil {
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil {
return ErrInvalidCredentials
}
if comparePassword(admin.PasswordHash, newPassword) == nil {
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil {
return errors.New("new password must differ from the current password")
}
passwordHash, err := hashPassword(newPassword, s.bcryptCost)
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost)
if err != nil {
return fmt.Errorf("auth: hash new password: %w", err)
}
@@ -317,36 +311,6 @@ 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 {
+1 -28
View File
@@ -3,7 +3,6 @@ package auth
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -129,7 +128,7 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
}{
{name: "empty username", password: "replacement-password"},
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
{name: "empty password", username: "admin", password: ""},
{name: "short password", username: "admin", password: "short"},
} {
t.Run(test.name, func(t *testing.T) {
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
@@ -139,32 +138,6 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
}
}
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)
-4
View File
@@ -277,10 +277,6 @@ 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)
-166
View File
@@ -2,175 +2,9 @@ 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")},
-56
View File
@@ -99,31 +99,6 @@ 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
@@ -297,19 +272,6 @@ 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
@@ -373,18 +335,6 @@ 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)
@@ -485,12 +435,6 @@ 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)
-375
View File
@@ -1,375 +0,0 @@
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
}
}
-39
View File
@@ -1,39 +0,0 @@
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
}
+4 -26
View File
@@ -38,14 +38,9 @@ type Manager struct {
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
qmiRadioOpener qmiRadioSessionOpener
nativeQMIRegistrationMu sync.Mutex
nativeQMIRegistrationInFlight map[string]struct{}
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
}
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
@@ -120,10 +115,6 @@ 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{}),
@@ -241,20 +232,7 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
state.opMu.Unlock()
}
manager.resetChangedClients()
// 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
return manager.List(), nil
}
func (manager *Manager) resetChangedClients() {
-76
View File
@@ -45,29 +45,6 @@ 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{
{
@@ -164,59 +141,6 @@ 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)
-702
View File
@@ -1,702 +0,0 @@
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
}
@@ -1,271 +0,0 @@
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)
}
})
}
}
+1 -14
View File
@@ -56,23 +56,10 @@ 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 {
if snapshot.ICCID == "" {
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
previousICCID = strings.TrimSpace(previousICCID)
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
-22
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"go.bug.st/serial"
)
@@ -22,22 +21,6 @@ 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
@@ -62,8 +45,3 @@ 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"
}
-24
View File
@@ -1,24 +0,0 @@
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)
}
}
}
-161
View File
@@ -1,161 +0,0 @@
//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)
}
-9
View File
@@ -1,9 +0,0 @@
//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)
}
-145
View File
@@ -1,145 +0,0 @@
// 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...)
}
-77
View File
@@ -1,77 +0,0 @@
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)
}
}
+2 -18
View File
@@ -344,14 +344,7 @@ 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
}
// 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
}
devices := s.devices.List()
configured, err := s.store.ListDevices(r.Context())
if err != nil {
s.writeStoreError(w, err)
@@ -359,9 +352,6 @@ 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 {
@@ -1482,13 +1472,7 @@ func physicalMatchesConfig(entry device.Device, config store.Device) bool {
return config.ModemIMEI == entry.Snapshot.IMEI
}
if 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.
return config.USBPath == candidate.USBPath
}
// Control and serial device nodes are allocation-order dependent. They are
// only legacy fallbacks when no physical USB path or readable IMEI exists.
@@ -1,72 +0,0 @@
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,27 +117,6 @@ 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{
+1 -1
View File
@@ -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 errors.Is(err, auth.ErrEmptyPassword):
case strings.Contains(err.Error(), "between 12 and 1024"):
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
case strings.Contains(err.Error(), "must differ"):
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
+47 -89
View File
@@ -29,7 +29,7 @@ import (
)
var (
errUnsafeDestination = errors.New("notification destination is not allowed")
errUnsafeDestination = errors.New("notification destination is not public")
errProviderRejected = errors.New("notification provider rejected the test")
telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
)
@@ -459,7 +459,7 @@ func (s *Server) handleNotificationTest(
w,
http.StatusBadRequest,
"unsafe_destination",
"notification destination resolved to an unusable or protected system address",
"notification destination must resolve only to public network addresses",
)
case errors.Is(err, errProviderRejected):
writeError(
@@ -905,12 +905,11 @@ func restrictedHTTPClient(
},
}
if strings.TrimSpace(proxy) != "" {
parsed, err := validateNotificationProxyURL(ctx, proxy)
parsed, err := validateOutboundURL(ctx, proxy, false)
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,
@@ -953,17 +952,6 @@ 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 {
@@ -997,42 +985,17 @@ 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)
}
var addresses []netip.Addr
if allowLocal {
addresses, err = resolveNotificationProxyAddresses(ctx, host)
} else {
addresses, err = resolvePublicAddresses(ctx, host)
}
addresses, err := resolvePublicAddresses(ctx, host)
if err != nil {
return nil, err
}
@@ -1108,68 +1071,54 @@ func dialNotification(
if len(failures) == 0 {
return nil, ctx.Err()
}
return nil, fmt.Errorf("dial notification destination: %w", errors.Join(failures...))
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
}
type notificationAllowedNetworksKey struct{}
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
ctx = 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
access := s.currentAccessConfig()
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
}
func notificationAddressAllowed(_ context.Context, address netip.Addr) bool {
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
address = address.Unmap()
if !notificationTransportAddress(address) {
// Even an administrator-provided exception must never turn a notification
// endpoint into a loopback or cloud-metadata request. Private/LAN and
// benchmark ranges may be explicitly allowed for local push gateways and
// DNS Fake-IP deployments, but these process-local destinations stay closed.
if !address.IsValid() || address.IsUnspecified() || address.IsLoopback() ||
address.IsMulticast() || address.IsLinkLocalUnicast() ||
address == netip.MustParseAddr("100.100.100.200") {
return false
}
for _, fakeIP := range notificationFakeIPNetworks {
if fakeIP.Contains(address) {
if publicNotificationAddress(address) {
return true
}
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
for _, prefix := range prefixes {
if prefix.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")
return false
}
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 == "" {
if normalized == "" || normalized == "localhost" ||
strings.HasSuffix(normalized, ".localhost") ||
normalized == "metadata" ||
strings.HasSuffix(normalized, ".internal") ||
strings.HasSuffix(normalized, ".local") {
return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
}
if literal, err := netip.ParseAddr(normalized); err == nil {
literal = literal.Unmap()
if (!allowLocal && !notificationAddressAllowed(ctx, literal)) ||
(allowLocal && !notificationProxyAddressAllowed(literal)) {
if !notificationAddressAllowed(ctx, literal) {
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
}
return []netip.Addr{literal}, nil
@@ -1184,8 +1133,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
result := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if (!allowLocal && !notificationAddressAllowed(ctx, address)) ||
(allowLocal && !notificationProxyAddressAllowed(address)) {
if !notificationAddressAllowed(ctx, address) {
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
}
result = append(result, address)
@@ -1193,11 +1141,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
return result, nil
}
var notificationFakeIPNetworks = []netip.Prefix{
netip.MustParsePrefix("198.18.0.0/15"),
}
var blockedNotificationDestinationNetworks = []netip.Prefix{
var blockedNotificationNetworks = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
@@ -1208,6 +1152,7 @@ var blockedNotificationDestinationNetworks = []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"),
@@ -1222,6 +1167,19 @@ var blockedNotificationDestinationNetworks = []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)
+27 -59
View File
@@ -369,10 +369,6 @@ 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,
@@ -766,28 +762,26 @@ func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
}
}
func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testing.T) {
func TestNotificationDestinationAddressPolicy(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", "224.0.0.1",
"255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::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",
}
for _, text := range blocked {
address := netip.MustParseAddr(text)
if notificationAddressAllowed(context.Background(), address) {
t.Errorf("%s was incorrectly accepted for notification transport", text)
if publicNotificationAddress(address) {
t.Errorf("%s was incorrectly accepted as public", text)
}
}
for _, text := range []string{
"1.1.1.1", "198.18.0.1", "2606:4700:4700::1111",
} {
for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
address := netip.MustParseAddr(text)
if !notificationAddressAllowed(context.Background(), address) {
t.Errorf("%s was incorrectly blocked for notification transport", text)
if !publicNotificationAddress(address) {
t.Errorf("%s was incorrectly blocked", text)
}
}
if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
t.Fatal("local notification destination was not blocked")
t.Fatal("localhost was not blocked")
}
if _, err := resolvePublicAddresses(
context.Background(),
@@ -795,53 +789,27 @@ func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testi
); 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)
allowedContext := context.WithValue(
context.Background(),
notificationAllowedNetworksKey{},
[]netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
)
if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
t.Fatalf("explicit Fake-IP notification allowlist = %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 := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
t.Fatal("unlisted metadata IP was allowed")
}
wideAllowedContext := context.WithValue(
context.Background(),
notificationAllowedNetworksKey{},
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
)
for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} {
if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil {
t.Fatalf("non-overridable destination %s was allowed", address)
}
}
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) {
+5 -2
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"net/netip"
"strings"
"testing"
"time"
@@ -57,8 +58,10 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
}
}
func TestTelegramPollingAcceptsFakeIPWithoutWebAccessAllowlist(t *testing.T) {
bot := &telegramBot{server: &Server{access: parsedAccessConfig{mode: "internal"}}}
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
}}}
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)
-118
View File
@@ -220,124 +220,6 @@ 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")
-117
View File
@@ -264,123 +264,6 @@ 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
}
+2 -3
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 19
const schemaVersion = 16
var ErrNotFound = errors.New("store: not found")
@@ -123,8 +123,7 @@ 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 == 19 && strings.Contains(statement, "ADD COLUMN"))
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
-29
View File
@@ -1,12 +1,6 @@
package update
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
@@ -28,26 +22,3 @@ 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 -22
View File
@@ -2,14 +2,11 @@ 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
@@ -43,23 +40,6 @@ 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.
@@ -81,7 +61,7 @@ func LatestRelease(ctx context.Context, repo, token string) (*Release, error) {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := githubHTTPClient.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("update: fetch latest release: %w", err)
}
@@ -140,7 +120,7 @@ func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := githubHTTPClient.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("update: download asset: %w", err)
}
+1 -65
View File
@@ -15,14 +15,12 @@ import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"time"
"vocat/internal/buildinfo"
@@ -151,7 +149,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 := downloadAssetWithProgress(ctx, logger, asset, opts.Token, tmp); err != nil {
if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
cleanup()
return err
}
@@ -208,68 +206,6 @@ 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.
-60
View File
@@ -1,60 +0,0 @@
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),
)
}
-56
View File
@@ -1,56 +0,0 @@
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)
}
}
}
+10 -135
View File
@@ -97,10 +97,9 @@ type ec20RadioCheckpoint struct {
}
var (
_ SIMIdentityReader = (*EC20Adapter)(nil)
_ AKAProvider = (*EC20Adapter)(nil)
_ PreferredAKAProvider = (*EC20Adapter)(nil)
_ RadioController = (*EC20Adapter)(nil)
_ SIMIdentityReader = (*EC20Adapter)(nil)
_ AKAProvider = (*EC20Adapter)(nil)
_ RadioController = (*EC20Adapter)(nil)
)
func NewEC20Adapter(
@@ -173,7 +172,6 @@ func (adapter *EC20Adapter) ReadIdentity(
HomeMCC: homeMCC,
HomeMNC: homeMNC,
}
identity = applyAssignedCarrierRoute(identity)
adapter.mu.Lock()
adapter.bindings[iccid] = ec20SIMBinding{
deviceID: deviceID,
@@ -210,11 +208,6 @@ 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 {
@@ -245,10 +238,9 @@ 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: "310280", mncLength: 3}, // AT&T / RedPocket GSMA.
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
{prefix: "23415", mncLength: 2}, // Vodafone UK.
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
}
for _, assignment := range assignments {
if strings.HasPrefix(imsi, assignment.prefix) {
@@ -399,46 +391,11 @@ 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
@@ -448,14 +405,6 @@ func (adapter *EC20Adapter) authenticateWithApplication(
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
}
@@ -958,28 +907,6 @@ 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,
@@ -1219,27 +1146,12 @@ func parseCRSMData(response modem.Response) ([]byte, error) {
}
func parseCUADData(response modem.Response) ([]byte, error) {
// 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 {
fields := parseCSV(valueAfterATPrefix(response, "+CUAD:"))
if len(fields) == 0 {
return nil, errors.New("CUAD response has no data")
}
data, err := hex.DecodeString(encoded.String())
value := fields[len(fields)-1]
data, err := hex.DecodeString(strings.Trim(value, `"`))
if err != nil || len(data) == 0 {
return nil, errors.New("CUAD response data is invalid")
}
@@ -1249,48 +1161,11 @@ 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
-94
View File
@@ -6,7 +6,6 @@ import (
"encoding/hex"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"testing"
@@ -422,7 +421,6 @@ 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)
@@ -432,23 +430,6 @@ 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,
) {
@@ -617,78 +598,3 @@ 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()
}
+2 -12
View File
@@ -168,7 +168,6 @@ func authenticateAKA(
provider vowifi.AKAProvider,
identity vowifi.SIMIdentity,
challenge digestChallenge,
preference string,
) (akaMaterial, error) {
nonce, err := decodeAKANonce(challenge.Nonce)
if err != nil {
@@ -179,18 +178,9 @@ func authenticateAKA(
var akaChallenge vowifi.AKAChallenge
copy(akaChallenge.RAND[:], nonce[:16])
copy(akaChallenge.AUTN[:], nonce[16:32])
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)
}
result, err := provider.Authenticate(ctx, identity, akaChallenge)
if err != nil {
application := "USIM"
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
application = "ISIM"
}
return akaMaterial{}, fmt.Errorf("ims: %s AKA authentication failed: %w", application, err)
return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", err)
}
if result.SynchronizationFailure || len(result.AUTS) > 0 {
if !result.SynchronizationFailure || len(result.AUTS) != 14 {
-37
View File
@@ -16,21 +16,6 @@ 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
}
@@ -75,7 +60,6 @@ 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)
@@ -109,7 +93,6 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
aka,
vowifi.SIMIdentity{},
digestChallenge{Nonce: nonce},
"",
)
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
@@ -119,26 +102,6 @@ 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{
+5 -56
View File
@@ -271,22 +271,13 @@ 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 + "@" + privateDomain
privateIdentity = imsi + "@" + domain
}
publicIdentity := config.PublicIdentity
if publicIdentity == "" {
publicIdentity = "sip:" + imsi + "@" + publicDomain
publicIdentity = "sip:" + imsi + "@" + domain
}
if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") ||
!strings.Contains(privateIdentity, "@") ||
@@ -545,9 +536,6 @@ func newSession(
}
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
@@ -566,10 +554,6 @@ func newSession(
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",
@@ -748,11 +732,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
if err != nil {
return nil, err
}
preference := ""
if vowifi.IsATT310280(session.request.Identity) {
preference = "isim_strict"
}
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference)
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge)
if err != nil {
return nil, err
}
@@ -812,10 +792,6 @@ 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
@@ -834,17 +810,6 @@ 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"
@@ -855,13 +820,6 @@ func (session *Session) buildRegister(
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),
@@ -875,23 +833,14 @@ func (session *Session) buildRegister(
fmt.Sprintf("Expires: %d", expires),
"Supported: " + supported,
"Allow: " + allow,
"User-Agent: " + userAgent,
"User-Agent: " + session.provider.config.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.securityClientValue(),
"Security-Client: "+session.securityProposal.headerValue(),
"Require: sec-agree",
"Proxy-Require: sec-agree",
)
-69
View File
@@ -442,75 +442,6 @@ func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
}
}
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++ {
-15
View File
@@ -12,8 +12,6 @@ import (
"sort"
"strconv"
"strings"
"vocat/internal/vowifi"
)
type SecurityMode string
@@ -145,19 +143,6 @@ func (proposal securityProposal) headerValue() string {
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"
+9 -4
View File
@@ -645,13 +645,18 @@ 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
}
return standardEPDGHostname(identity.HomeMCC, identity.HomeMNC), nil
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
}
func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) {
+3 -3
View File
@@ -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 applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
return 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 applyAssignedCarrierRoute(SIMIdentity{
return 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) {
-10
View File
@@ -125,16 +125,6 @@ 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{
-8
View File
@@ -317,14 +317,6 @@ 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.
+4 -10
View File
@@ -345,14 +345,11 @@ 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" | "$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."
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."
if [ "$result" = "created" ]; then
FIRST_INSTALL=1
INITIAL_ADMIN_PASSWORD="$secret"
@@ -535,12 +532,9 @@ fi
resolve_target_version
skip_if_equal
download_and_verify
ensure_data_dir
# 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
ensure_data_dir
bootstrap_admin
setup_env
write_service
enable_and_start
-1
View File
@@ -6,7 +6,6 @@
"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": {
+2 -38
View File
@@ -83,29 +83,7 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
raw?: boolean;
}
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> {
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
const method = (options.method || "GET").toUpperCase();
const headers = new Headers(options.headers);
const formBody = typeof FormData !== "undefined" && options.body instanceof FormData;
@@ -138,6 +116,7 @@ async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: b
: { 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"
? {
@@ -145,26 +124,11 @@ async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: b
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 { ArrowSyncRegular, SaveRegular } from "@fluentui/react-icons";
import { SaveRegular } from "@fluentui/react-icons";
import { cx } from "../../lib/utils";
import { Button, Input, Modal, Select, Spinner, Tag } from "../ui";
import { isQmiControl } from "./shared";
@@ -17,7 +17,6 @@ export interface DeviceAddDialogProps {
addConfig: AddDeviceForm;
addSaving: boolean;
onClose: () => void;
onRefresh: () => void;
onSelectDevice: (d: DiscoveredDevice) => void;
onConfigChange: (next: AddDeviceForm) => void;
onSave: () => void;
@@ -86,12 +85,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
</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="mb-3 text-sm text-gray-500">{t("选择一个“未配置”的设备,系统将自动填充 AT 端口与识别信息。")}</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">
-57
View File
@@ -1,57 +0,0 @@
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];
}
-7
View File
@@ -178,11 +178,8 @@ 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",
@@ -197,7 +194,6 @@ 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",
@@ -246,8 +242,6 @@ 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",
@@ -999,7 +993,6 @@ export const EN_DICT: Record<string, string> = {
: "Latest",
: "Load More",
: "Refresh",
"刷新设备": "Refresh Devices",
// ---- 代理 / 国家规则(i18n 补充) ----
"绑定:": "Bound:",
+32 -61
View File
@@ -23,11 +23,6 @@ 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";
@@ -135,10 +130,6 @@ 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() {
@@ -161,7 +152,6 @@ 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);
@@ -219,8 +209,6 @@ export default function AutomaticTasksPage() {
return () => window.clearInterval(timer);
}, [load, reloadRuns]);
useEffect(() => () => profileRequestGuardRef.current.invalidate(), []);
function changeRunsPage(page: number) {
runsPageRef.current = page;
setRunsPage(page);
@@ -235,53 +223,37 @@ export default function AutomaticTasksPage() {
void fetchRuns(1, pageSize);
}
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "", currentICCID = "") => {
if (!deviceId) {
profileRequestGuardRef.current.invalidate();
setProfiles([]);
setProfileLoading(false);
return;
}
const requestID = profileRequestGuardRef.current.begin();
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => {
setProfiles([]);
if (!deviceId) return;
setProfileLoading(true);
let groups: EsimProfileGroup[] = [];
let inventoryError: unknown;
try {
const data = await api<{ profiles?: EsimProfileGroup[] }>(`/devices/${encodeURIComponent(deviceId)}/esim`);
groups = data.profiles || [];
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;
});
} catch (error) {
inventoryError = error;
message.error(apiMessage(error));
} finally {
setProfileLoading(false);
}
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,
@@ -300,7 +272,7 @@ export default function AutomaticTasksPage() {
message: task.payload?.message || "",
durationSeconds: task.payload?.durationSeconds || 30,
} : emptyForm(deviceId);
if (selectedDevice?.deviceType === "usb_sim_reader") {
if (devices.find((device) => device.id === deviceId)?.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")) {
@@ -308,18 +280,17 @@ export default function AutomaticTasksPage() {
}
setForm(next);
setOpen(true);
void loadProfiles(deviceId, next.profileIccid, currentDeviceICCID(selectedDevice));
void loadProfiles(deviceId, next.profileIccid);
}
function chooseDevice(deviceId: string) {
const selectedDevice = devices.find((device) => device.id === deviceId);
const reader = selectedDevice?.deviceType === "usb_sim_reader";
const reader = devices.find((device) => device.id === deviceId)?.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, "", currentDeviceICCID(selectedDevice));
void loadProfiles(deviceId);
}
function chooseProfile(iccid: string) {
@@ -339,7 +310,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("请选择 SIM 卡或 eSIM Profile"));
if (!form.profileIccid) return message.warning(t("请选择 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短信和通话任务"));
}
@@ -373,7 +344,7 @@ export default function AutomaticTasksPage() {
body,
});
message.success(t(form.id ? "自动任务已更新" : "自动任务已创建"));
closeEditor();
setOpen(false);
await load();
} catch (error) {
message.error(apiMessage(error));
@@ -440,8 +411,8 @@ export default function AutomaticTasksPage() {
<PageHeader
title={t("自动任务")}
subtitle={advancedTasksAvailable
? t("按周期使用指定 SIM 卡或切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
: t("按周期使用指定 SIM 卡或切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
? t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
: t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
/>
@@ -451,7 +422,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("设备 / SIM / Profile")}</th>
<th className="px-4 py-3">{t("设备 / 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>
@@ -495,7 +466,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("添加任务后,系统会按设备排队并在执行前校验目标 SIM / Profile")}</div>
<div className="mt-1 text-xs">{t("添加任务后,系统会按设备排队并在执行前校验目标 Profile")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
@@ -527,11 +498,11 @@ export default function AutomaticTasksPage() {
) : null}
</div>
<Modal open={open} onClose={closeEditor} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
<Modal open={open} onClose={() => setOpen(false)} 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("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("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("任务类型")}</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}
@@ -548,7 +519,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={closeEditor}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></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>
</Modal>
</div>
);
+4 -11
View File
@@ -136,17 +136,11 @@ 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");
const devices = Array.isArray(res?.devices) ? res!.devices! : [];
setDiscovered(devices);
setDiscovered(Array.isArray(res?.devices) ? res!.devices! : []);
} catch {
setDiscovered([]);
/* ignore */
} finally {
setDiscovering(false);
}
@@ -302,13 +296,13 @@ export default function DevicesPage() {
try {
await api("/devices/actions/rescan", { method: "POST" });
message.success(t("设备重新扫描完成"));
await Promise.all([loadDevices(true), loadDiscovered()]);
await loadDevices(true);
} catch (e) {
message.error(apiMessage(e) || t("重新扫描失败"));
} finally {
setRescanning(false);
}
}, [loadDevices, loadDiscovered]);
}, [loadDevices]);
const handleOpenSms = useCallback(() => {
const id = selectedIdRef.current;
@@ -742,7 +736,6 @@ export default function DevicesPage() {
addConfig={addConfig}
addSaving={addSaving}
onClose={() => setAddOpen(false)}
onRefresh={() => void loadDiscovered()}
onSelectDevice={selectDiscovered}
onConfigChange={setAddConfig}
onSave={saveAdd}
-68
View File
@@ -1,68 +0,0 @@
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);
});