mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-17 21:33:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26f56af10 | ||
|
|
251c9f5de7 | ||
|
|
8f35a4520e |
@@ -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
|
|
||||||
@@ -2,18 +2,11 @@ name: Pull request size limit
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
branches:
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
- master
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-size-limit-${{ github.event.pull_request.number }}
|
group: pr-size-limit-${{ github.event.pull_request.number }}
|
||||||
@@ -21,262 +14,67 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
enforce-size-limit:
|
enforce-size-limit:
|
||||||
# 保持这个名字不变,这样你 Ruleset 里的 Required Check 不需要修改
|
|
||||||
name: Enforce 5,000-line limit
|
name: Enforce 5,000-line limit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 2
|
||||||
|
|
||||||
env:
|
env:
|
||||||
MAX_CHANGED_LINES: "5000"
|
MAX_CHANGED_LINES: "5000"
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout trusted base repository
|
- name: Reject oversized pull request
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check conflicts and pull request size
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Checking PR #${PR_NUMBER}"
|
api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"
|
||||||
echo "Base branch: ${BASE_REF}"
|
response="$({
|
||||||
|
curl --fail-with-body --silent --show-error \
|
||||||
############################################################
|
|
||||||
# Helper: comment on and close rejected PR
|
|
||||||
############################################################
|
|
||||||
|
|
||||||
reject_pr() {
|
|
||||||
local message="$1"
|
|
||||||
|
|
||||||
echo "::error::${message}"
|
|
||||||
|
|
||||||
COMMENT_PAYLOAD="$(
|
|
||||||
jq -nc \
|
|
||||||
--arg body "${message}" \
|
|
||||||
'{body: $body}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
echo "Posting rejection comment..."
|
|
||||||
|
|
||||||
curl \
|
|
||||||
--fail-with-body \
|
|
||||||
--silent \
|
|
||||||
--show-error \
|
|
||||||
--request POST \
|
|
||||||
--header "Accept: application/vnd.github+json" \
|
--header "Accept: application/vnd.github+json" \
|
||||||
--header "Authorization: Bearer ${GH_TOKEN}" \
|
--header "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
"${api_url}"
|
||||||
--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
|
|
||||||
|
|
||||||
|
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
|
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
|
fi
|
||||||
|
|
||||||
echo "No merge conflicts detected."
|
changed_lines=$((additions + deletions))
|
||||||
|
|
||||||
############################################################
|
|
||||||
# STEP 2: Determine merge base
|
|
||||||
############################################################
|
|
||||||
|
|
||||||
if ! MERGE_BASE="$(
|
|
||||||
git merge-base "${BASE_COMMIT}" "${PR_COMMIT}"
|
|
||||||
)"; then
|
|
||||||
|
|
||||||
echo "::error::Unable to determine merge base."
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "### Pull request policy"
|
|
||||||
echo
|
|
||||||
echo "- Merge conflicts: ✅ None"
|
|
||||||
echo "- Diff calculation: ⚠️ Failed"
|
|
||||||
} >> "${GITHUB_STEP_SUMMARY}"
|
|
||||||
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Merge base: ${MERGE_BASE}"
|
|
||||||
|
|
||||||
############################################################
|
|
||||||
# STEP 3: Calculate actual PR changed lines
|
|
||||||
############################################################
|
|
||||||
|
|
||||||
NUMSTAT_FILE="$(mktemp)"
|
|
||||||
|
|
||||||
git diff \
|
|
||||||
--no-ext-diff \
|
|
||||||
--no-textconv \
|
|
||||||
--numstat \
|
|
||||||
"${MERGE_BASE}" \
|
|
||||||
"${PR_COMMIT}" \
|
|
||||||
> "${NUMSTAT_FILE}"
|
|
||||||
|
|
||||||
ADDITIONS="$(
|
|
||||||
awk '
|
|
||||||
$1 ~ /^[0-9]+$/ {
|
|
||||||
total += $1
|
|
||||||
}
|
|
||||||
|
|
||||||
END {
|
|
||||||
print total + 0
|
|
||||||
}
|
|
||||||
' "${NUMSTAT_FILE}"
|
|
||||||
)"
|
|
||||||
|
|
||||||
DELETIONS="$(
|
|
||||||
awk '
|
|
||||||
$2 ~ /^[0-9]+$/ {
|
|
||||||
total += $2
|
|
||||||
}
|
|
||||||
|
|
||||||
END {
|
|
||||||
print total + 0
|
|
||||||
}
|
|
||||||
' "${NUMSTAT_FILE}"
|
|
||||||
)"
|
|
||||||
|
|
||||||
CHANGED_FILES="$(
|
|
||||||
awk '
|
|
||||||
END {
|
|
||||||
print NR + 0
|
|
||||||
}
|
|
||||||
' "${NUMSTAT_FILE}"
|
|
||||||
)"
|
|
||||||
|
|
||||||
CHANGED_LINES=$((ADDITIONS + DELETIONS))
|
|
||||||
|
|
||||||
############################################################
|
|
||||||
# Action summary
|
|
||||||
############################################################
|
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "### Pull request policy"
|
echo "### Pull request size"
|
||||||
echo
|
echo
|
||||||
echo "- Merge conflicts: ✅ None"
|
echo "- Additions: ${additions}"
|
||||||
echo "- Changed files: ${CHANGED_FILES}"
|
echo "- Deletions: ${deletions}"
|
||||||
echo "- Additions: ${ADDITIONS}"
|
echo "- Total changed lines: ${changed_lines}"
|
||||||
echo "- Deletions: ${DELETIONS}"
|
echo "- Limit: ${MAX_CHANGED_LINES}"
|
||||||
echo "- Total changed lines: ${CHANGED_LINES}"
|
} >>"${GITHUB_STEP_SUMMARY}"
|
||||||
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."
|
|
||||||
|
|
||||||
|
if (( changed_lines <= MAX_CHANGED_LINES )); then
|
||||||
|
echo "Pull request is within the ${MAX_CHANGED_LINES}-line limit."
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
############################################################
|
curl --fail-with-body --silent --show-error \
|
||||||
# PASS
|
--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
|
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."
|
||||||
echo "Pull request passed all policy checks."
|
comment_payload="$(jq -nc --arg body "${message}" '{body: $body}')"
|
||||||
echo "No merge conflicts."
|
curl --fail-with-body --silent --show-error \
|
||||||
echo "Changed lines: ${CHANGED_LINES}/${MAX_CHANGED_LINES}."
|
--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 "::error::Pull request changes ${changed_lines} lines; the maximum is ${MAX_CHANGED_LINES}."
|
||||||
echo
|
exit 1
|
||||||
echo "### Result"
|
|
||||||
echo
|
|
||||||
echo "✅ Pull request passed."
|
|
||||||
} >> "${GITHUB_STEP_SUMMARY}"
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
Copyright (c) 2013, Dustin L. Howett. All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
1. Redistributions of source code must retain the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer.
|
|
||||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer in the documentation
|
|
||||||
and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
||||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
|
||||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
||||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
||||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
||||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
||||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
||||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
||||||
POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
|
|
||||||
The views and conclusions contained in the software and documentation are
|
|
||||||
those of the authors and should not be interpreted as representing official
|
|
||||||
policies, either expressed or implied, of the FreeBSD Project.
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
Parts of this package were made available under the license covering the Go
|
|
||||||
language and all attended core libraries. That license follows.
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
* Redistributions of source code must retain the above copyright
|
|
||||||
notice, this list of conditions and the following disclaimer.
|
|
||||||
* Redistributions in binary form must reproduce the above copyright
|
|
||||||
notice, this list of conditions and the following disclaimer in the
|
|
||||||
documentation and/or other materials provided with the distribution.
|
|
||||||
* Neither the name of Google Inc. nor the names of its contributors may be
|
|
||||||
used to endorse or promote products derived from this software without
|
|
||||||
specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
||||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
|
||||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
||||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
||||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
||||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
||||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
||||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
||||||
POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -212,10 +212,6 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
|||||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
||||||
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
||||||
|
|
||||||
User-supplied Apple carrier bundles can be converted into reviewable,
|
|
||||||
allow-listed carrier profiles with `vocat carrier import-ipcc`; see
|
|
||||||
[docs/CARRIER_IPCC_IMPORT.md](docs/CARRIER_IPCC_IMPORT.md).
|
|
||||||
|
|
||||||
Administrator credentials are stored only in SQLite. Initialize an empty
|
Administrator credentials are stored only in SQLite. Initialize an empty
|
||||||
database once with `vocat bootstrap-admin`; environment variables and JSON
|
database once with `vocat bootstrap-admin`; environment variables and JSON
|
||||||
configuration cannot set or overwrite the administrator username or password.
|
configuration cannot set or overwrite the administrator username or password.
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ func runBootstrapAdmin(args []string) error {
|
|||||||
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
|
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
|
||||||
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
|
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')
|
password, err := reader.ReadString('\n')
|
||||||
if err != nil && !errors.Is(err, io.EOF) {
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
return fmt.Errorf("read password: %w", err)
|
return fmt.Errorf("read password: %w", err)
|
||||||
}
|
}
|
||||||
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
|
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
|
||||||
if password == "" {
|
if len(password) < 6 || len(password) > 1024 {
|
||||||
return errors.New("bootstrap password cannot be empty")
|
return errors.New("bootstrap password must contain between 6 and 1024 characters")
|
||||||
}
|
}
|
||||||
adminUsername := strings.TrimSpace(*username)
|
adminUsername := strings.TrimSpace(*username)
|
||||||
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"vocat/internal/config"
|
|
||||||
"vocat/internal/vowifi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func runCarrier(args []string, stdout io.Writer) error {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return errors.New("usage: vocat carrier import-ipcc [flags] FILE.ipcc")
|
|
||||||
}
|
|
||||||
switch args[0] {
|
|
||||||
case "import-ipcc":
|
|
||||||
return runCarrierImportIPCC(args[1:], stdout)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown carrier subcommand %q", args[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCarrierImportIPCC(args []string, stdout io.Writer) error {
|
|
||||||
flags := flag.NewFlagSet("carrier import-ipcc", flag.ContinueOnError)
|
|
||||||
flags.SetOutput(io.Discard)
|
|
||||||
var bundle string
|
|
||||||
var profileID string
|
|
||||||
var profileDir string
|
|
||||||
var install bool
|
|
||||||
var documentOnly bool
|
|
||||||
flags.StringVar(&bundle, "bundle", "", "bundle name when an IPCC contains more than one carrier bundle")
|
|
||||||
flags.StringVar(&profileID, "id", "", "override the generated carrier profile ID")
|
|
||||||
flags.StringVar(&profileDir, "profile-dir", "", "installation directory (default: next to the VoCat database)")
|
|
||||||
flags.BoolVar(&install, "install", false, "atomically install the reviewed generated profile")
|
|
||||||
flags.BoolVar(&documentOnly, "document-only", false, "print only the generated carrier profile document")
|
|
||||||
if err := flags.Parse(args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flags.NArg() != 1 {
|
|
||||||
return errors.New("usage: vocat carrier import-ipcc [--bundle NAME] [--id ID] [--document-only] [--install] [--profile-dir DIR] FILE.ipcc")
|
|
||||||
}
|
|
||||||
if documentOnly && install {
|
|
||||||
return errors.New("--document-only and --install cannot be used together")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(profileDir) != "" && !install {
|
|
||||||
return errors.New("--profile-dir requires --install")
|
|
||||||
}
|
|
||||||
result, err := vowifi.ImportCarrierIPCC(flags.Arg(0), vowifi.IPCCImportOptions{
|
|
||||||
Bundle: bundle,
|
|
||||||
ProfileID: profileID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if documentOnly {
|
|
||||||
_, err := stdout.Write(result.Document)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
installedPath := ""
|
|
||||||
if install {
|
|
||||||
profileDir = strings.TrimSpace(profileDir)
|
|
||||||
if profileDir == "" {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("load configuration for carrier profile directory: %w", err)
|
|
||||||
}
|
|
||||||
profileDir = filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
|
||||||
}
|
|
||||||
installedPath, err = vowifi.InstallCarrierIPCCResult(result, profileDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if absolute, absoluteErr := filepath.Abs(installedPath); absoluteErr == nil {
|
|
||||||
installedPath = absolute
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output := struct {
|
|
||||||
vowifi.IPCCImportResult
|
|
||||||
InstalledPath string `json:"installed_path,omitempty"`
|
|
||||||
RestartRequired bool `json:"restart_required,omitempty"`
|
|
||||||
}{
|
|
||||||
IPCCImportResult: result,
|
|
||||||
InstalledPath: installedPath,
|
|
||||||
RestartRequired: installedPath != "",
|
|
||||||
}
|
|
||||||
encoder := json.NewEncoder(stdout)
|
|
||||||
encoder.SetIndent("", " ")
|
|
||||||
return encoder.Encode(output)
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"howett.net/plist"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRunCarrierImportIPCCPreviewsAndInstallsExplicitly(t *testing.T) {
|
|
||||||
archivePath := filepath.Join(t.TempDir(), "test.ipcc")
|
|
||||||
file, err := os.Create(archivePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
archive := zip.NewWriter(file)
|
|
||||||
entry, err := archive.Create("Payload/Test.bundle/carrier.plist")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var encoded bytes.Buffer
|
|
||||||
if err := plist.NewEncoder(&encoded).Encode(map[string]any{
|
|
||||||
"CarrierName": "Test Carrier",
|
|
||||||
"SupportedSIMs": []any{"99901"},
|
|
||||||
"SupportedPLMNs": []any{"99901"},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := archive.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var preview bytes.Buffer
|
|
||||||
if err := runCarrier([]string{"import-ipcc", "--document-only", archivePath}, &preview); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var document struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(preview.Bytes(), &document); err != nil || document.Version != 1 {
|
|
||||||
t.Fatalf("preview = %q, version=%d, error=%v", preview.String(), document.Version, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
installDir := t.TempDir()
|
|
||||||
var output bytes.Buffer
|
|
||||||
if err := runCarrier([]string{
|
|
||||||
"import-ipcc", "--id", "cli-test", "--install", "--profile-dir", installDir, archivePath,
|
|
||||||
}, &output); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var installed struct {
|
|
||||||
InstalledPath string `json:"installed_path"`
|
|
||||||
RestartRequired bool `json:"restart_required"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(output.Bytes(), &installed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !installed.RestartRequired || filepath.Base(installed.InstalledPath) != "cli-test.json" {
|
|
||||||
t.Fatalf("install output = %s", output.String())
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(installDir, "cli-test.json")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,15 +21,6 @@ Usage:
|
|||||||
vocat serve Run the server in the foreground (use from a TTY when
|
vocat serve Run the server in the foreground (use from a TTY when
|
||||||
vocat without arguments would enter the menu).
|
vocat without arguments would enter the menu).
|
||||||
vocat version Print the build version and exit.
|
vocat version Print the build version and exit.
|
||||||
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths.
|
|
||||||
Use --repair-dji-qmi on Linux to safely wake a factory-ID
|
|
||||||
DJI/Baiwang 2ca3:4006 QMI interface without changing NV.
|
|
||||||
vocat carrier import-ipcc [flags] FILE.ipcc
|
|
||||||
Convert an Apple carrier bundle into a reviewable VoCat
|
|
||||||
profile. Preview is the default; --install writes it to
|
|
||||||
carrier-profiles.d and takes effect after restart.
|
|
||||||
Flags: --bundle NAME --id ID --document-only --install
|
|
||||||
--profile-dir DIR.
|
|
||||||
vocat update Check GitHub for a newer release and self-update.
|
vocat update Check GitHub for a newer release and self-update.
|
||||||
Flags:
|
Flags:
|
||||||
--check Only report whether an update is available.
|
--check Only report whether an update is available.
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"vocat/internal/modem"
|
|
||||||
"vocat/internal/pcsc"
|
|
||||||
"vocat/internal/proxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
type doctorCheck struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Evidence any `json:"evidence,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type doctorReport struct {
|
|
||||||
Time time.Time `json:"time"`
|
|
||||||
OS string `json:"os"`
|
|
||||||
Arch string `json:"arch"`
|
|
||||||
Checks []doctorCheck `json:"checks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type djiQMIRepairResult struct {
|
|
||||||
USBName string `json:"usb_name"`
|
|
||||||
Interface string `json:"interface"`
|
|
||||||
USBDevice string `json:"usb_device"`
|
|
||||||
OriginalDriver string `json:"original_driver,omitempty"`
|
|
||||||
ControlDevice string `json:"control_device"`
|
|
||||||
NetworkInterface string `json:"network_interface,omitempty"`
|
|
||||||
QMIProbe string `json:"qmi_probe"`
|
|
||||||
Attempts int `json:"attempts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDoctor(args []string) error {
|
|
||||||
flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
|
||||||
flags.SetOutput(os.Stderr)
|
|
||||||
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
|
|
||||||
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
|
|
||||||
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password")
|
|
||||||
repairDJI := flags.Bool("repair-dji-qmi", false, "rebind DJI 2ca3:4006 interface 4 to qmi_wwan and assert DTR (Linux/root only; no NV write)")
|
|
||||||
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
|
|
||||||
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
|
|
||||||
if err := flags.Parse(args); err != nil {
|
|
||||||
if errors.Is(err, flag.ErrHelp) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flags.NArg() != 0 || *timeout <= 0 || *timeout > time.Minute {
|
|
||||||
return errors.New("usage: vocat doctor [--repair-dji-qmi] [--proxy host:port] [--proxy-username name] [--proxy-password-env ENV] [--json]")
|
|
||||||
}
|
|
||||||
report := doctorReport{Time: time.Now().UTC(), OS: runtime.GOOS, Arch: runtime.GOARCH}
|
|
||||||
add := func(name, status, code, message string, evidence any) {
|
|
||||||
report.Checks = append(report.Checks, doctorCheck{Name: name, Status: status, Code: code, Message: message, Evidence: evidence})
|
|
||||||
}
|
|
||||||
|
|
||||||
if data, err := os.ReadFile("/proc/version"); err == nil && strings.Contains(strings.ToLower(string(data)), "microsoft") {
|
|
||||||
add("host", "warning", "wsl_usbip_detected", "WSL/USBIP detected; QMI control transfers may time out even when /dev/cdc-wdm exists", nil)
|
|
||||||
} else {
|
|
||||||
add("host", "passed", "native_host", "No WSL kernel marker detected", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
|
||||||
defer cancel()
|
|
||||||
if *repairDJI {
|
|
||||||
result, err := repairDJIQMI(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("repair DJI QMI binding: %w", err)
|
|
||||||
}
|
|
||||||
add("dji_qmi_repair", "passed", "dji_qmi_dtr_asserted", "DJI interface 4 was bound to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx)
|
|
||||||
if discoverErr != nil {
|
|
||||||
add("modem_discovery", "failed", "modem_discovery_failed", discoverErr.Error(), nil)
|
|
||||||
} else if len(candidates) == 0 {
|
|
||||||
add("modem_discovery", "warning", "no_modem", "No USB modem was discovered", nil)
|
|
||||||
} else {
|
|
||||||
add("modem_discovery", "passed", "modem_discovered", fmt.Sprintf("Discovered %d modem candidate(s)", len(candidates)), candidates)
|
|
||||||
}
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
name := "modem:" + candidate.ID
|
|
||||||
if candidate.HasATPort() {
|
|
||||||
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 5*time.Second))
|
|
||||||
client, openErr := (modem.SerialOpener{}).Open(probeContext, candidate.ATPort)
|
|
||||||
if openErr != nil {
|
|
||||||
add(name+":at", "warning", "at_open_failed", openErr.Error(), candidate.ATPort.OpenPath())
|
|
||||||
} else {
|
|
||||||
response, commandErr := client.Execute(probeContext, "AT+CFUN?")
|
|
||||||
_ = client.Close()
|
|
||||||
if commandErr != nil {
|
|
||||||
add(name+":at", "warning", "at_probe_failed", commandErr.Error(), candidate.ATPort.OpenPath())
|
|
||||||
} else {
|
|
||||||
add(name+":at", "passed", "at_ready", "AT control channel responded to a read-only CFUN query", response.Text())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelProbe()
|
|
||||||
} else {
|
|
||||||
add(name+":at", "failed", "at_missing", "No AT port was selected", nil)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(candidate.QMIControl) == "" {
|
|
||||||
add(name+":qmi", "warning", "qmi_missing", "No cdc-wdm/QMI control node was discovered", nil)
|
|
||||||
} else if qmicli, lookErr := exec.LookPath("qmicli"); lookErr != nil {
|
|
||||||
add(name+":qmi", "warning", "qmicli_missing", "QMI node exists but qmicli is unavailable for an active DMS check", candidate.QMIControl)
|
|
||||||
} else {
|
|
||||||
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 8*time.Second))
|
|
||||||
command := exec.CommandContext(probeContext, qmicli, "-d", candidate.QMIControl, "--dms-get-operating-mode")
|
|
||||||
output, commandErr := command.CombinedOutput()
|
|
||||||
message := strings.TrimSpace(string(output))
|
|
||||||
cancelProbe()
|
|
||||||
if commandErr != nil {
|
|
||||||
code := "qmi_cid_failed"
|
|
||||||
if errors.Is(probeContext.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timed out") {
|
|
||||||
code = "qmi_cid_timeout"
|
|
||||||
}
|
|
||||||
add(name+":qmi", "failed", code, "qmicli DMS client allocation/read failed", message)
|
|
||||||
} else {
|
|
||||||
add(name+":qmi", "passed", "qmi_dms_ready", "qmicli allocated DMS and completed a read-only request", message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readers, readerErr := pcsc.New().Readers(ctx)
|
|
||||||
if readerErr == nil {
|
|
||||||
add("pcsc", "passed", "pcsc_ready", fmt.Sprintf("PC/SC reported %d reader(s)", len(readers)), readers)
|
|
||||||
} else if errors.Is(readerErr, pcsc.ErrUnsupported) || errors.Is(readerErr, pcsc.ErrUnavailable) {
|
|
||||||
add("pcsc", "warning", "pcsc_unavailable", readerErr.Error(), nil)
|
|
||||||
} else {
|
|
||||||
add("pcsc", "failed", "pcsc_failed", readerErr.Error(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(*proxyAddress) != "" {
|
|
||||||
password := os.Getenv(strings.TrimSpace(*passwordEnv))
|
|
||||||
probeContext, cancelProbe := context.WithTimeout(context.Background(), *timeout)
|
|
||||||
result, probeErr := proxy.ProbeSOCKS5(probeContext, *proxyAddress, *proxyUsername, password, *timeout)
|
|
||||||
cancelProbe()
|
|
||||||
status := "passed"
|
|
||||||
if probeErr != nil {
|
|
||||||
status = "failed"
|
|
||||||
}
|
|
||||||
add("proxy_udp", status, result.Diagnosis, result.Hint, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *jsonOutput {
|
|
||||||
encoder := json.NewEncoder(os.Stdout)
|
|
||||||
encoder.SetIndent("", " ")
|
|
||||||
return encoder.Encode(report)
|
|
||||||
}
|
|
||||||
for _, check := range report.Checks {
|
|
||||||
fmt.Printf("%-8s %-26s %-28s %s\n", strings.ToUpper(check.Status), check.Name, check.Code, check.Message)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func minDuration(left, right time.Duration) time.Duration {
|
|
||||||
if left < right {
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
return right
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
djiVendorID = "2ca3"
|
|
||||||
djiProductID = "4006"
|
|
||||||
djiQMIIndex = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
type usbControlTransfer struct {
|
|
||||||
RequestType uint8
|
|
||||||
Request uint8
|
|
||||||
Value uint16
|
|
||||||
Index uint16
|
|
||||||
Length uint16
|
|
||||||
Timeout uint32
|
|
||||||
Data uintptr
|
|
||||||
}
|
|
||||||
|
|
||||||
func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) {
|
|
||||||
return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) {
|
|
||||||
return repairDJIQMIAt(attemptContext, "/sys", "/dev")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func retryDJIQMI(
|
|
||||||
ctx context.Context,
|
|
||||||
maxAttempts int,
|
|
||||||
delay time.Duration,
|
|
||||||
attempt func(context.Context) (djiQMIRepairResult, error),
|
|
||||||
) (djiQMIRepairResult, error) {
|
|
||||||
var result djiQMIRepairResult
|
|
||||||
var err error
|
|
||||||
for attemptNumber := 1; attemptNumber <= maxAttempts; attemptNumber++ {
|
|
||||||
result, err = attempt(ctx)
|
|
||||||
result.Attempts = attemptNumber
|
|
||||||
if err == nil {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
timer := time.NewTimer(time.Duration(attemptNumber) * delay)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
timer.Stop()
|
|
||||||
return result, errors.Join(err, ctx.Err())
|
|
||||||
case <-timer.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMIRepairResult, returnErr error) {
|
|
||||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
|
||||||
entries, err := os.ReadDir(usbRoot)
|
|
||||||
if err != nil {
|
|
||||||
return result, fmt.Errorf("read USB topology: %w", err)
|
|
||||||
}
|
|
||||||
var usbNames []string
|
|
||||||
for _, entry := range entries {
|
|
||||||
devicePath := filepath.Join(usbRoot, entry.Name())
|
|
||||||
vendor, vendorErr := readTrimmedFile(filepath.Join(devicePath, "idVendor"))
|
|
||||||
product, productErr := readTrimmedFile(filepath.Join(devicePath, "idProduct"))
|
|
||||||
if vendorErr == nil && productErr == nil &&
|
|
||||||
strings.EqualFold(vendor, djiVendorID) && strings.EqualFold(product, djiProductID) {
|
|
||||||
usbNames = append(usbNames, entry.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(usbNames) != 1 {
|
|
||||||
return result, fmt.Errorf("expected exactly one DJI %s:%s USB device, found %d", djiVendorID, djiProductID, len(usbNames))
|
|
||||||
}
|
|
||||||
result.USBName = usbNames[0]
|
|
||||||
result.Interface = fmt.Sprintf("%s:1.%d", result.USBName, djiQMIIndex)
|
|
||||||
devicePath := filepath.Join(usbRoot, result.USBName)
|
|
||||||
interfacePath := filepath.Join(usbRoot, result.Interface)
|
|
||||||
if _, err := os.Stat(interfacePath); err != nil {
|
|
||||||
return result, fmt.Errorf("DJI QMI interface %s unavailable: %w", result.Interface, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
busNumber, err := readUSBNumber(filepath.Join(devicePath, "busnum"))
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
deviceNumber, err := readUSBNumber(filepath.Join(devicePath, "devnum"))
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber))
|
|
||||||
|
|
||||||
result.OriginalDriver = usbInterfaceDriver(interfacePath)
|
|
||||||
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
|
|
||||||
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver)
|
|
||||||
}
|
|
||||||
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
|
||||||
if _, err := os.Stat(filepath.Join(driversRoot, "qmi_wwan")); err != nil {
|
|
||||||
modprobe, lookErr := exec.LookPath("modprobe")
|
|
||||||
if lookErr != nil {
|
|
||||||
return result, errors.New("qmi_wwan is not loaded and modprobe is unavailable")
|
|
||||||
}
|
|
||||||
if output, loadErr := exec.CommandContext(ctx, modprobe, "qmi_wwan").CombinedOutput(); loadErr != nil {
|
|
||||||
return result, fmt.Errorf("load qmi_wwan: %w: %s", loadErr, strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interfaceDetached := false
|
|
||||||
restoreOriginal := func() {
|
|
||||||
if !interfaceDetached {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
|
||||||
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
|
|
||||||
}
|
|
||||||
if result.OriginalDriver != "" {
|
|
||||||
_ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if returnErr != nil {
|
|
||||||
restoreOriginal()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if result.OriginalDriver != "" {
|
|
||||||
if err := writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "unbind"), result.Interface); err != nil {
|
|
||||||
return result, fmt.Errorf("unbind %s from %s: %w", result.OriginalDriver, result.Interface, err)
|
|
||||||
}
|
|
||||||
interfaceDetached = true
|
|
||||||
}
|
|
||||||
if err := assertUSBDTR(result.USBDevice, djiQMIIndex); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
bindPath := filepath.Join(driversRoot, "qmi_wwan", "bind")
|
|
||||||
if err := writeSysfs(bindPath, result.Interface); err != nil {
|
|
||||||
newIDErr := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "new_id"), djiVendorID+" "+djiProductID)
|
|
||||||
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
|
|
||||||
return result, fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
|
|
||||||
}
|
|
||||||
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
|
|
||||||
if retryErr := writeSysfs(bindPath, result.Interface); retryErr != nil {
|
|
||||||
return result, fmt.Errorf("bind qmi_wwan to %s: %w", result.Interface, retryErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
|
|
||||||
return result, fmt.Errorf("interface %s driver is %q after qmi_wwan bind", result.Interface, driver)
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for {
|
|
||||||
result.ControlDevice = firstDeviceNode(filepath.Join(interfacePath, "usbmisc"), devRoot, "cdc-wdm")
|
|
||||||
result.NetworkInterface = firstEntryName(filepath.Join(interfacePath, "net"), "")
|
|
||||||
if result.ControlDevice != "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return result, fmt.Errorf("qmi_wwan bound but no cdc-wdm node appeared for %s", result.Interface)
|
|
||||||
}
|
|
||||||
time.Sleep(25 * time.Millisecond)
|
|
||||||
}
|
|
||||||
time.Sleep(250 * time.Millisecond)
|
|
||||||
qmicli, err := exec.LookPath("qmicli")
|
|
||||||
if err != nil {
|
|
||||||
return result, errors.New("qmicli is required to verify DJI QMI readiness after DTR repair")
|
|
||||||
}
|
|
||||||
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
|
|
||||||
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
|
||||||
cancelProbe()
|
|
||||||
result.QMIProbe = strings.TrimSpace(string(output))
|
|
||||||
if probeErr != nil {
|
|
||||||
if probeContext.Err() != nil {
|
|
||||||
probeErr = errors.Join(probeErr, probeContext.Err())
|
|
||||||
}
|
|
||||||
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
|
||||||
}
|
|
||||||
interfaceDetached = false
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertUSBDTR(devicePath string, interfaceIndex int) error {
|
|
||||||
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open USB device %s: %w", devicePath, err)
|
|
||||||
}
|
|
||||||
defer unix.Close(fd)
|
|
||||||
if err := setUSBControlLineState(fd, interfaceIndex, false); err != nil {
|
|
||||||
return fmt.Errorf("clear CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if err := setUSBControlLineState(fd, interfaceIndex, true); err != nil {
|
|
||||||
return fmt.Errorf("assert CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
|
||||||
}
|
|
||||||
// QDC507 acknowledges the control transfer before its QMI firmware is ready.
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setUSBControlLineState(fd, interfaceIndex int, dtr bool) error {
|
|
||||||
var value uint16
|
|
||||||
if dtr {
|
|
||||||
value = 1 // USB_CDC_CTRL_DTR
|
|
||||||
}
|
|
||||||
transfer := usbControlTransfer{
|
|
||||||
RequestType: 0x21, // host-to-device, class, interface
|
|
||||||
Request: 0x22, // USB_CDC_REQ_SET_CONTROL_LINE_STATE
|
|
||||||
Value: value,
|
|
||||||
Index: uint16(interfaceIndex),
|
|
||||||
Timeout: 5000,
|
|
||||||
}
|
|
||||||
const ioctlDirectionReadWrite = uintptr(3)
|
|
||||||
request := ioctlDirectionReadWrite<<30 |
|
|
||||||
uintptr(unsafe.Sizeof(transfer))<<16 |
|
|
||||||
uintptr('U')<<8
|
|
||||||
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), request, uintptr(unsafe.Pointer(&transfer)))
|
|
||||||
if errno != 0 {
|
|
||||||
return errno
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readTrimmedFile(path string) (string, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(data)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readUSBNumber(path string) (int, error) {
|
|
||||||
value, err := readTrimmedFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("read %s: %w", filepath.Base(path), err)
|
|
||||||
}
|
|
||||||
number, err := strconv.Atoi(value)
|
|
||||||
if err != nil || number < 1 || number > 999 {
|
|
||||||
return 0, fmt.Errorf("invalid %s %q", filepath.Base(path), value)
|
|
||||||
}
|
|
||||||
return number, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func usbInterfaceDriver(interfacePath string) string {
|
|
||||||
resolved, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver"))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Base(resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeSysfs(path, value string) error {
|
|
||||||
file, err := os.OpenFile(path, os.O_WRONLY, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, writeErr := file.WriteString(value)
|
|
||||||
closeErr := file.Close()
|
|
||||||
return errors.Join(writeErr, closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstDeviceNode(directory, devRoot, prefix string) string {
|
|
||||||
name := firstEntryName(directory, prefix)
|
|
||||||
if name == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(devRoot, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstEntryName(directory, prefix string) string {
|
|
||||||
entries, err := os.ReadDir(directory)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if strings.HasPrefix(entry.Name(), prefix) {
|
|
||||||
return entry.Name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDJIUSBControlTransferLayout(t *testing.T) {
|
|
||||||
var transfer usbControlTransfer
|
|
||||||
if got := unsafe.Sizeof(transfer); got != 24 {
|
|
||||||
t.Fatalf("usbControlTransfer size = %d, want 24", got)
|
|
||||||
}
|
|
||||||
if transfer.RequestType != 0 || transfer.Request != 0 {
|
|
||||||
t.Fatal("zero-value transfer unexpectedly initialized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadUSBNumber(t *testing.T) {
|
|
||||||
directory := t.TempDir()
|
|
||||||
path := filepath.Join(directory, "busnum")
|
|
||||||
if err := os.WriteFile(path, []byte("12\n"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, err := readUSBNumber(path); err != nil || got != 12 {
|
|
||||||
t.Fatalf("readUSBNumber() = %d, %v, want 12, nil", got, err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := readUSBNumber(path); err == nil {
|
|
||||||
t.Fatal("readUSBNumber(0) unexpectedly succeeded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "missing")
|
|
||||||
if err := writeSysfs(path, "value"); err == nil {
|
|
||||||
t.Fatal("writeSysfs(missing) unexpectedly succeeded")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("missing sysfs path was created: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
|
||||||
attempts++
|
|
||||||
if attempts < 3 {
|
|
||||||
return djiQMIRepairResult{}, errors.New("transient QMI timeout")
|
|
||||||
}
|
|
||||||
return djiQMIRepairResult{ControlDevice: "/dev/cdc-wdm0"}, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("retryDJIQMI() error = %v", err)
|
|
||||||
}
|
|
||||||
if attempts != 3 || result.Attempts != 3 {
|
|
||||||
t.Fatalf("attempts = %d, result.Attempts = %d, want 3", attempts, result.Attempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryDJIQMIStopsAfterBoundedAttempts(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
_, err := retryDJIQMI(context.Background(), 2, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
|
||||||
attempts++
|
|
||||||
return djiQMIRepairResult{}, errors.New("persistent failure")
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("retryDJIQMI() unexpectedly succeeded")
|
|
||||||
}
|
|
||||||
if attempts != 2 {
|
|
||||||
t.Fatalf("attempts = %d, want 2", attempts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
func repairDJIQMI(context.Context) (djiQMIRepairResult, error) {
|
|
||||||
return djiQMIRepairResult{}, errors.New("DJI QMI repair is supported only on Linux")
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestDoctorHelpIsSuccessful(t *testing.T) {
|
|
||||||
if err := runDoctor([]string{"--help"}); err != nil {
|
|
||||||
t.Fatalf("runDoctor(--help) error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-48
@@ -27,7 +27,6 @@ import (
|
|||||||
"vocat/internal/extensions"
|
"vocat/internal/extensions"
|
||||||
"vocat/internal/httpsmode"
|
"vocat/internal/httpsmode"
|
||||||
"vocat/internal/loghub"
|
"vocat/internal/loghub"
|
||||||
"vocat/internal/modem"
|
|
||||||
"vocat/internal/pcsc"
|
"vocat/internal/pcsc"
|
||||||
"vocat/internal/server"
|
"vocat/internal/server"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
@@ -77,16 +76,6 @@ func main() {
|
|||||||
logger.Error("update failed", "error", err)
|
logger.Error("update failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
case "doctor":
|
|
||||||
if err := runDoctor(rest); err != nil {
|
|
||||||
logger.Error("doctor failed", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
case "carrier":
|
|
||||||
if err := runCarrier(rest, os.Stdout); err != nil {
|
|
||||||
logger.Error("carrier command failed", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
case "menu":
|
case "menu":
|
||||||
if err := runMenu(logger); err != nil {
|
if err := runMenu(logger); err != nil {
|
||||||
logger.Error("menu failed", "error", err)
|
logger.Error("menu failed", "error", err)
|
||||||
@@ -130,10 +119,6 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load configuration: %w", err)
|
return fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
carrierProfileDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
|
||||||
if err := vowifi.LoadCarrierProfileDirectory(carrierProfileDir); err != nil {
|
|
||||||
return fmt.Errorf("load installed carrier profiles: %w", err)
|
|
||||||
}
|
|
||||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -206,7 +191,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cardReaders := pcsc.New()
|
cardReaders := pcsc.New()
|
||||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
|
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create device manager: %w", err)
|
return fmt.Errorf("create device manager: %w", err)
|
||||||
}
|
}
|
||||||
@@ -595,13 +580,6 @@ func configureVoWiFiRuntime(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
nativeQMIAdapter, err := vowifi.NewNativeQMIAdapter(nativeQMIControllerMapper{Mapper: mapper, Devices: deviceManager}, func(deviceID string) bool {
|
|
||||||
deviceConfig, configErr := database.Device(context.Background(), deviceID)
|
|
||||||
return configErr == nil && deviceConfig.VoWiFiEnabled
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) {
|
pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) {
|
||||||
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
|
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
@@ -627,10 +605,8 @@ func configureVoWiFiRuntime(
|
|||||||
adapter := vowifiDeviceAdapter(ec20Adapter)
|
adapter := vowifiDeviceAdapter(ec20Adapter)
|
||||||
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
|
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||||
adapter = pcscAdapter
|
adapter = pcscAdapter
|
||||||
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
|
||||||
adapter = nativeQMIAdapter
|
|
||||||
}
|
}
|
||||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
|
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -729,25 +705,26 @@ func newVoWiFiOrchestrator(
|
|||||||
deviceConfig store.Device,
|
deviceConfig store.Device,
|
||||||
database *store.Store,
|
database *store.Store,
|
||||||
adapter vowifiDeviceAdapter,
|
adapter vowifiDeviceAdapter,
|
||||||
logger *slog.Logger,
|
|
||||||
) (*vowifi.Orchestrator, error) {
|
) (*vowifi.Orchestrator, error) {
|
||||||
apn := deviceConfig.APN
|
apn := deviceConfig.APN
|
||||||
if apn == "" {
|
if apn == "" {
|
||||||
apn = "ims"
|
apn = "ims"
|
||||||
}
|
}
|
||||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
||||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||||
}
|
}
|
||||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||||
Logger: logger,
|
// The userspace SWu data plane carries protected P-CSCF signalling over
|
||||||
// Carrier-specific transport and SMSC defaults live in the shared data
|
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
|
||||||
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
Transport: "tcp",
|
||||||
// alternate transport only if no SIP response was observed.
|
TransportByPLMN: map[string]string{
|
||||||
Transport: "tcp",
|
"23410": "udp",
|
||||||
AutoTransportFallback: true,
|
"234010": "udp",
|
||||||
|
},
|
||||||
|
// Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes
|
||||||
|
// this service-centre number for manual SMS setup.
|
||||||
|
SMSCenter: "+447785016005",
|
||||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||||
extra, _ := json.Marshal(map[string]any{
|
extra, _ := json.Marshal(map[string]any{
|
||||||
"transport": "ims",
|
"transport": "ims",
|
||||||
@@ -859,9 +836,9 @@ func provisionDiscoveredDevices(
|
|||||||
}
|
}
|
||||||
for _, discovered := range manager.List() {
|
for _, discovered := range manager.List() {
|
||||||
candidate := discovered.Candidate
|
candidate := discovered.Candidate
|
||||||
deviceType := provisionedDeviceType(candidate)
|
|
||||||
backend := "at"
|
backend := "at"
|
||||||
control := candidate.ATPort.OpenPath()
|
control := candidate.ATPort.OpenPath()
|
||||||
|
deviceType := store.DeviceTypePCIeEC20EC25
|
||||||
esimTransport := backend
|
esimTransport := backend
|
||||||
if candidate.QMIControl != "" {
|
if candidate.QMIControl != "" {
|
||||||
backend = "qmi"
|
backend = "qmi"
|
||||||
@@ -878,7 +855,6 @@ func provisionDiscoveredDevices(
|
|||||||
if name == "" || strings.EqualFold(name, "Android") {
|
if name == "" || strings.EqualFold(name, "Android") {
|
||||||
name = "Quectel EC20 / EC25"
|
name = "Quectel EC20 / EC25"
|
||||||
}
|
}
|
||||||
supportsSMS := deviceType != store.DeviceTypeWiFi410
|
|
||||||
if err := database.UpsertDevice(ctx, store.Device{
|
if err := database.UpsertDevice(ctx, store.Device{
|
||||||
ID: discovered.ID,
|
ID: discovered.ID,
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -895,7 +871,7 @@ func provisionDiscoveredDevices(
|
|||||||
DeviceBackend: backend,
|
DeviceBackend: backend,
|
||||||
ESIMTransport: esimTransport,
|
ESIMTransport: esimTransport,
|
||||||
NetworkEnabled: false,
|
NetworkEnabled: false,
|
||||||
SMSEnabled: supportsSMS,
|
SMSEnabled: true,
|
||||||
VoWiFiEnabled: true,
|
VoWiFiEnabled: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -904,15 +880,6 @@ func provisionDiscoveredDevices(
|
|||||||
return nil
|
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
|
// 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
|
// entry to the log_events table, so runtime logs survive restarts and can be
|
||||||
// pruned by the configured retention policy.
|
// pruned by the configured retention policy.
|
||||||
|
|||||||
@@ -217,24 +217,3 @@ func TestEnforceCardRegionIgnoresUnknownOrNotReadySIM(t *testing.T) {
|
|||||||
t.Fatalf("expected no card policies, got %d", len(policies))
|
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
@@ -198,7 +198,8 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
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)
|
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,6 +215,7 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Print(m.newUsername(admin.Username))
|
fmt.Print(m.newUsername(admin.Username))
|
||||||
username, err := reader.ReadString('\n')
|
username, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -523,7 +525,6 @@ func menuUpdate(m *menu, logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
fmt.Println(m.updateChecking())
|
fmt.Println(m.updateChecking())
|
||||||
if err := update.Run(logger, []string{"--repo", repo}); err != nil {
|
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 fmt.Errorf("%w: %v", errUpdateFailed, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -595,7 +596,7 @@ func (m *menu) msg(key string) string {
|
|||||||
"prompt": {"请选择: ", "Select: "},
|
"prompt": {"请选择: ", "Select: "},
|
||||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||||
"new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "},
|
"new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "},
|
||||||
"new_pw": {"新密码: ", "New password: "},
|
"new_pw": {"新密码 (至少 6 位): ", "New password (min 6 chars): "},
|
||||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||||
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
|
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
|
||||||
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
||||||
@@ -687,11 +688,10 @@ func (m *menu) errorPrefix(err error) string {
|
|||||||
}
|
}
|
||||||
return "重启失败。"
|
return "重启失败。"
|
||||||
case errors.Is(err, errUpdateFailed):
|
case errors.Is(err, errUpdateFailed):
|
||||||
detail := strings.TrimPrefix(err.Error(), errUpdateFailed.Error()+": ")
|
|
||||||
if m.lang == "en" {
|
if m.lang == "en" {
|
||||||
return "Update failed: " + detail
|
return "Update failed."
|
||||||
}
|
}
|
||||||
return "更新失败: " + detail
|
return "更新失败。"
|
||||||
case errors.Is(err, errMenuConfig):
|
case errors.Is(err, errMenuConfig):
|
||||||
if m.lang == "en" {
|
if m.lang == "en" {
|
||||||
return "Failed to load configuration."
|
return "Failed to load configuration."
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"vocat/internal/device"
|
|
||||||
"vocat/internal/vowifi"
|
|
||||||
"vocat/internal/vowifi/integration"
|
|
||||||
)
|
|
||||||
|
|
||||||
// nativeQMIControllerMapper keeps the configured Web/API device ID stable
|
|
||||||
// while Linux exposes the physical MHI modem under its discovery ID.
|
|
||||||
type nativeQMIControllerMapper struct {
|
|
||||||
Mapper integration.ATMapper
|
|
||||||
Devices *device.Manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) physical(configuredID string) (string, error) {
|
|
||||||
entry, err := mapper.Mapper.Get(configuredID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return entry.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Context, id string) (string, string, string, string, string, error) {
|
|
||||||
physical, err := mapper.physical(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", "", "", err
|
|
||||||
}
|
|
||||||
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id string) (vowifi.SIMMetadata, error) {
|
|
||||||
return mapper.Mapper.ReadSIMMetadata(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
|
||||||
physical, err := mapper.physical(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return mapper.Devices.ProbeNativeQMIApplication(ctx, physical, preference)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) ([]byte, error) {
|
|
||||||
physical, err := mapper.physical(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mapper.Devices.AuthenticateNativeQMI(ctx, physical, aid, apdu)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) NativeQMIRadioSnapshot(ctx context.Context, id string) (int, bool, error) {
|
|
||||||
physical, err := mapper.physical(id)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
return mapper.Devices.NativeQMIRadioSnapshot(ctx, physical)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) StopNativeQMICellularData(ctx context.Context, id string) error {
|
|
||||||
physical, err := mapper.physical(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return mapper.Devices.StopNativeQMICellularData(ctx, physical)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) SetNativeQMIRadioOff(ctx context.Context, id string, off bool) error {
|
|
||||||
physical, err := mapper.physical(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return mapper.Devices.SetNativeQMIRadioOff(ctx, physical, off)
|
|
||||||
}
|
|
||||||
@@ -195,25 +195,6 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
|||||||
|
|
||||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||||
|
|
||||||
## Apple IPCC 运营商规则导入
|
|
||||||
|
|
||||||
VoCat 可以离线解析用户提供的 `.ipcc`,将 Apple 的 XML/二进制 plist
|
|
||||||
转换为可审查的运营商 Profile。默认只预览,不会修改配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vocat carrier import-ipcc Carrier_iPhone.ipcc
|
|
||||||
```
|
|
||||||
|
|
||||||
确认警告和匹配范围后,使用 `--install` 安装;重启 VoCat 后生效:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vocat carrier import-ipcc --install Carrier_iPhone.ipcc
|
|
||||||
```
|
|
||||||
|
|
||||||
导入器不会复制关闭证书验证、绕过运营商授权、APN 凭据、紧急呼叫或
|
|
||||||
设备型号专属媒体参数。完整字段和冲突处理说明见
|
|
||||||
[CARRIER_IPCC_IMPORT.md](CARRIER_IPCC_IMPORT.md)。
|
|
||||||
|
|
||||||
## Telegram 机器人
|
## Telegram 机器人
|
||||||
|
|
||||||
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.43.0
|
||||||
howett.net/plist v1.0.1
|
|
||||||
modernc.org/sqlite v1.38.2
|
modernc.org/sqlite v1.38.2
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,8 +18,6 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/libc v1.66.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -11,26 +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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
|
||||||
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
@@ -48,15 +37,8 @@ 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/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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
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.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
|
||||||
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
|||||||
+12
-48
@@ -1,7 +1,6 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -21,13 +20,8 @@ var (
|
|||||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrInvalidCSRF = errors.New("invalid csrf token")
|
ErrInvalidCSRF = errors.New("invalid csrf token")
|
||||||
ErrEmptyPassword = errors.New("password cannot be empty")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const bcryptPasswordLimit = 72
|
|
||||||
|
|
||||||
var longPasswordHashPrefix = []byte("$vocat-sha256$")
|
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
SessionTTL time.Duration
|
SessionTTL time.Duration
|
||||||
BcryptCost int
|
BcryptCost int
|
||||||
@@ -91,14 +85,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
|
|||||||
current, err := s.store.CurrentAdmin(ctx)
|
current, err := s.store.CurrentAdmin(ctx)
|
||||||
if err == nil &&
|
if err == nil &&
|
||||||
current.Username == username &&
|
current.Username == username &&
|
||||||
comparePassword(current.PasswordHash, password) == nil {
|
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||||
return fmt.Errorf("auth: read configured admin: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("auth: hash admin password: %w", err)
|
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") {
|
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")
|
return errors.New("administrator username must contain between 1 and 64 characters without control whitespace")
|
||||||
}
|
}
|
||||||
if password == "" {
|
if len(password) < 6 || len(password) > 1024 {
|
||||||
return ErrEmptyPassword
|
return errors.New("administrator password must contain between 6 and 1024 characters")
|
||||||
}
|
}
|
||||||
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
||||||
return fmt.Errorf("auth: reset administrator credentials: %w", err)
|
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) {
|
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
|
||||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
_ = comparePassword(s.dummyHash, password)
|
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
|
||||||
return Credentials{}, ErrInvalidCredentials
|
return Credentials{}, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
|
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
|
return Credentials{}, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,24 +284,24 @@ func (s *Service) ChangePassword(
|
|||||||
currentPassword string,
|
currentPassword string,
|
||||||
newPassword string,
|
newPassword string,
|
||||||
) error {
|
) error {
|
||||||
if newPassword == "" {
|
if len(newPassword) < 6 || len(newPassword) > 1024 {
|
||||||
return ErrEmptyPassword
|
return errors.New("new password must contain between 6 and 1024 characters")
|
||||||
}
|
}
|
||||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
_ = comparePassword(s.dummyHash, currentPassword)
|
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword))
|
||||||
return ErrInvalidCredentials
|
return ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("auth: find admin: %w", err)
|
return fmt.Errorf("auth: find admin: %w", err)
|
||||||
}
|
}
|
||||||
if comparePassword(admin.PasswordHash, currentPassword) != nil {
|
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil {
|
||||||
return ErrInvalidCredentials
|
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")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("auth: hash new password: %w", err)
|
return fmt.Errorf("auth: hash new password: %w", err)
|
||||||
}
|
}
|
||||||
@@ -317,36 +311,6 @@ func (s *Service) ChangePassword(
|
|||||||
return nil
|
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) {
|
func randomToken() (string, error) {
|
||||||
buffer := make([]byte, 32)
|
buffer := make([]byte, 32)
|
||||||
if _, err := rand.Read(buffer); err != nil {
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -129,7 +128,7 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "empty username", password: "replacement-password"},
|
{name: "empty username", password: "replacement-password"},
|
||||||
{name: "control whitespace", username: "bad\tname", 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) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
|
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) {
|
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
service := newTestService(t)
|
service := newTestService(t)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package device
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,25 +51,6 @@ func CountryForMCC(mcc string) (string, bool) {
|
|||||||
return country, len(country) == 2
|
return country, len(country) == 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCCsByCountry returns the complete MCC grouping from the embedded carrier
|
|
||||||
// database, keyed by ISO alpha-2 country/territory code. The returned map and
|
|
||||||
// slices are new values and may be safely modified by callers.
|
|
||||||
func MCCsByCountry() map[string][]string {
|
|
||||||
result := make(map[string][]string)
|
|
||||||
for mcc, rawCountry := range globalCarrierDatabase.Countries {
|
|
||||||
country := strings.ToUpper(strings.TrimSpace(rawCountry))
|
|
||||||
mcc = strings.TrimSpace(mcc)
|
|
||||||
if len(country) != 2 || len(mcc) != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[country] = append(result[country], mcc)
|
|
||||||
}
|
|
||||||
for country := range result {
|
|
||||||
sort.Strings(result[country])
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
var globalCarrierDatabase = func() carrierDatabase {
|
var globalCarrierDatabase = func() carrierDatabase {
|
||||||
var database carrierDatabase
|
var database carrierDatabase
|
||||||
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
||||||
@@ -124,7 +104,7 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
|
|||||||
// several customer-facing carriers authenticate through the same home PLMN.
|
// several customer-facing carriers authenticate through the same home PLMN.
|
||||||
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
|
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
|
||||||
imsi := strings.TrimSpace(identity.IMSI)
|
imsi := strings.TrimSpace(identity.IMSI)
|
||||||
if !decimalDigits(imsi, 5, 20) || IsPlaceholderIMSI(imsi) {
|
if !decimalDigits(imsi, 5, 20) {
|
||||||
return "", "", "", false
|
return "", "", "", false
|
||||||
}
|
}
|
||||||
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
|
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
|
||||||
|
|||||||
@@ -277,10 +277,6 @@ func (manager *Manager) SetFlight(
|
|||||||
if manager.candidateFor(state).HardwareKind == "pcsc" {
|
if manager.candidateFor(state).HardwareKind == "pcsc" {
|
||||||
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
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))
|
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
manager.setResult(id, state, nil, err)
|
manager.setResult(id, state, nil, err)
|
||||||
|
|||||||
@@ -2,209 +2,9 @@ package device
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"testing"
|
"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
|
|
||||||
imei string
|
|
||||||
imeiErr error
|
|
||||||
openedAIDs [][]byte
|
|
||||||
openChannel byte
|
|
||||||
openErr error
|
|
||||||
closedChannels []byte
|
|
||||||
apdus [][]byte
|
|
||||||
apduResponse []byte
|
|
||||||
apduErr 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 (session *fakeQMIRadioSession) GetIMEI(context.Context) (string, error) {
|
|
||||||
return session.imei, session.imeiErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *fakeQMIRadioSession) OpenLogicalChannel(_ context.Context, _ uint8, aid []byte) (byte, error) {
|
|
||||||
session.openedAIDs = append(session.openedAIDs, append([]byte(nil), aid...))
|
|
||||||
if session.openErr != nil {
|
|
||||||
return 0, session.openErr
|
|
||||||
}
|
|
||||||
if session.openChannel == 0 {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
return session.openChannel, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *fakeQMIRadioSession) CloseLogicalChannel(_ context.Context, _ uint8, channel uint8) error {
|
|
||||||
session.closedChannels = append(session.closedChannels, channel)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *fakeQMIRadioSession) SendAPDU(_ context.Context, _ uint8, _ uint8, command []byte) ([]byte, error) {
|
|
||||||
session.apdus = append(session.apdus, append([]byte(nil), command...))
|
|
||||||
return append([]byte(nil), session.apduResponse...), session.apduErr
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
func TestSetFlightPreservesRawCFUNZero(t *testing.T) {
|
||||||
client := &transcriptClient{steps: []clientStep{
|
client := &transcriptClient{steps: []clientStep{
|
||||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 0")},
|
{command: "AT+CFUN?", response: okResponse("+CFUN: 0")},
|
||||||
|
|||||||
@@ -99,31 +99,6 @@ func (manager *Manager) SetNetwork(
|
|||||||
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
||||||
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
|
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)
|
result, err := setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
|
||||||
if err != nil && (request.Username != "" || request.Password != "") {
|
if err != nil && (request.Username != "" || request.Password != "") {
|
||||||
// qmi-network output is outside our control and may echo values read
|
// 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) {
|
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?")
|
response, err := manager.ExecuteAT(ctx, id, "AT+COPS?")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return OperatorSelection{}, err
|
return OperatorSelection{}, err
|
||||||
@@ -373,18 +335,6 @@ func (manager *Manager) SetOperatorSelection(
|
|||||||
if err := manager.validateActive(id, state); err != nil {
|
if err := manager.validateActive(id, state); err != nil {
|
||||||
return OperatorSelection{}, err
|
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))
|
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
manager.setResult(id, state, nil, err)
|
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 {
|
if err := manager.validateActive(id, state); err != nil {
|
||||||
return OperatorSelection{}, err
|
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))
|
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
manager.setResult(id, state, nil, err)
|
manager.setResult(id, state, nil, err)
|
||||||
|
|||||||
+8
-294
@@ -8,8 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
|
||||||
|
|
||||||
"vocat/internal/i18n"
|
"vocat/internal/i18n"
|
||||||
"vocat/internal/modem"
|
"vocat/internal/modem"
|
||||||
"vocat/internal/pcsc"
|
"vocat/internal/pcsc"
|
||||||
@@ -161,20 +159,12 @@ func encodeICCID(digits string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildEnableProfileRequest(iccid string) ([]byte, error) {
|
func buildEnableProfileRequest(iccid string) ([]byte, error) {
|
||||||
return buildEnableProfileRequestWithRefresh(iccid, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildEnableProfileRequestWithRefresh(iccid string, refresh bool) ([]byte, error) {
|
|
||||||
bcd, err := encodeICCID(iccid)
|
bcd, err := encodeICCID(iccid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
profileID := derConstruct(0xA0, derEncode(0x5A, bcd))
|
profileID := derConstruct(0xA0, derEncode(0x5A, bcd))
|
||||||
refreshFlag := byte(0x00)
|
return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{0xFF})), nil
|
||||||
if refresh {
|
|
||||||
refreshFlag = 0xFF
|
|
||||||
}
|
|
||||||
return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{refreshFlag})), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCSIM extracts the payload and status word from an AT+CSIM response.
|
// parseCSIM extracts the payload and status word from an AT+CSIM response.
|
||||||
@@ -205,74 +195,9 @@ type euiccChannel struct {
|
|||||||
id string
|
id string
|
||||||
channel int
|
channel int
|
||||||
pcscSession *pcsc.Session
|
pcscSession *pcsc.Session
|
||||||
qmiSession nativeQMIEuiccSession
|
|
||||||
qmiSlot uint8
|
|
||||||
resetOnClose bool
|
resetOnClose bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (channel *euiccChannel) registerProfileRefresh(ctx context.Context) (bool, error) {
|
|
||||||
refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession)
|
|
||||||
if !ok {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := refreshSession.RegisterUIMRefresh(ctx); err != nil {
|
|
||||||
var unsupported *qmi.NotSupportedError
|
|
||||||
if errors.As(err, &unsupported) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (channel *euiccChannel) completeProfileRefresh(ctx context.Context) error {
|
|
||||||
refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return refreshSession.CompleteUIMRefresh(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (channel *euiccChannel) acknowledgeProfileRefresh(ctx context.Context) error {
|
|
||||||
refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return refreshSession.AcknowledgeUIMRefresh(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (channel *euiccChannel) recoverCATBusy(ctx context.Context) error {
|
|
||||||
if channel.qmiSession == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// A power cycle must happen while the CAT2 client remains registered, or
|
|
||||||
// the card can issue its first proactive command before VoCat is listening
|
|
||||||
// and immediately become busy again.
|
|
||||||
if channel.channel > 0 {
|
|
||||||
_ = channel.qmiSession.CloseLogicalChannel(ctx, channel.qmiSlot, byte(channel.channel))
|
|
||||||
channel.channel = 0
|
|
||||||
}
|
|
||||||
power, ok := channel.qmiSession.(interface {
|
|
||||||
PowerOffSIM(context.Context, uint8) error
|
|
||||||
PowerOnSIM(context.Context, uint8) error
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := power.PowerOffSIM(ctx, channel.qmiSlot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
}
|
|
||||||
if err := power.PowerOnSIM(ctx, channel.qmiSlot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return channel.completeProfileRefresh(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
|
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
|
||||||
// makes the eUICC decrypt/write sizeable SCP03t segments on-card, which can exceed
|
// makes the eUICC decrypt/write sizeable SCP03t segments on-card, which can exceed
|
||||||
// the modem's default 3s command timeout, so eSIM APDUs get a longer budget.
|
// the modem's default 3s command timeout, so eSIM APDUs get a longer budget.
|
||||||
@@ -364,9 +289,6 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
|||||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||||
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
|
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
|
||||||
}
|
}
|
||||||
if strings.EqualFold(manager.backendFor(state), "qmi") && isNativeQMICandidate(candidate) {
|
|
||||||
return manager.openQMIEuiccOnceAID(ctx, id, candidate, aidHex)
|
|
||||||
}
|
|
||||||
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
|
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
|
||||||
// firmware requires the explicit one-byte expected length: Le=00 opens a
|
// firmware requires the explicit one-byte expected length: Le=00 opens a
|
||||||
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
|
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
|
||||||
@@ -405,38 +327,6 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
|||||||
return channel, nil
|
return channel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
|
|
||||||
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
|
|
||||||
aid, err := hex.DecodeString(aidHex)
|
|
||||||
if err != nil || len(aid) == 0 || len(aid) > 255 {
|
|
||||||
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
|
|
||||||
}
|
|
||||||
if manager.qmiRadioOpener == nil {
|
|
||||||
return nil, errors.New("esim: QMI UIM transport is unavailable")
|
|
||||||
}
|
|
||||||
openContext, cancel := context.WithTimeout(ctx, csimAPDUTimeout)
|
|
||||||
defer cancel()
|
|
||||||
radioSession, err := manager.qmiRadioOpener(openContext, candidate.QMIControl)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("esim: open QMI UIM transport: %w", err)
|
|
||||||
}
|
|
||||||
session, ok := radioSession.(nativeQMIEuiccSession)
|
|
||||||
if !ok {
|
|
||||||
_ = radioSession.Close()
|
|
||||||
return nil, errors.New("esim: QMI UIM transport does not support logical channels")
|
|
||||||
}
|
|
||||||
const slot uint8 = 1
|
|
||||||
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid)
|
|
||||||
if err != nil {
|
|
||||||
_ = session.Close()
|
|
||||||
return nil, fmt.Errorf("%w: %v", errNoEUICC, err)
|
|
||||||
}
|
|
||||||
return &euiccChannel{
|
|
||||||
manager: manager, id: id, channel: int(logicalChannel),
|
|
||||||
qmiSession: session, qmiSlot: slot,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
|
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
|
||||||
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
|
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
|
||||||
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
|
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
|
||||||
@@ -517,14 +407,6 @@ func isTransientEuiccCME(err error) bool {
|
|||||||
|
|
||||||
// close releases the logical channel (MANAGE CHANNEL close).
|
// close releases the logical channel (MANAGE CHANNEL close).
|
||||||
func (channel *euiccChannel) close(ctx context.Context) {
|
func (channel *euiccChannel) close(ctx context.Context) {
|
||||||
if channel.qmiSession != nil {
|
|
||||||
if channel.channel > 0 {
|
|
||||||
_ = channel.qmiSession.CloseLogicalChannel(ctx, channel.qmiSlot, byte(channel.channel))
|
|
||||||
}
|
|
||||||
_ = channel.qmiSession.Close()
|
|
||||||
channel.qmiSession = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
|
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
|
||||||
_, _, _ = channel.exchange(ctx, closeAPDU)
|
_, _, _ = channel.exchange(ctx, closeAPDU)
|
||||||
if channel.pcscSession != nil {
|
if channel.pcscSession != nil {
|
||||||
@@ -538,17 +420,6 @@ func (channel *euiccChannel) close(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
|
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
|
||||||
if channel.qmiSession != nil {
|
|
||||||
raw, err := channel.qmiSession.SendAPDU(ctx, channel.qmiSlot, byte(channel.channel), apdu)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
if len(raw) < 2 {
|
|
||||||
return nil, 0, fmt.Errorf("esim: short QMI UIM APDU response")
|
|
||||||
}
|
|
||||||
sw := int(raw[len(raw)-2])<<8 | int(raw[len(raw)-1])
|
|
||||||
return raw[:len(raw)-2], sw, nil
|
|
||||||
}
|
|
||||||
if channel.pcscSession != nil {
|
if channel.pcscSession != nil {
|
||||||
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
|
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
|
||||||
return payload, int(sw), err
|
return payload, int(sw), err
|
||||||
@@ -781,9 +652,9 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
|||||||
if iccid == "" {
|
if iccid == "" {
|
||||||
return errors.New("esim: an ICCID is required")
|
return errors.New("esim: an ICCID is required")
|
||||||
}
|
}
|
||||||
_, nativeQMI, nativeErr := manager.nativeQMIControl(id)
|
der, err := buildEnableProfileRequest(iccid)
|
||||||
if nativeErr != nil {
|
if err != nil {
|
||||||
return nativeErr
|
return err
|
||||||
}
|
}
|
||||||
manager.lockESIM()
|
manager.lockESIM()
|
||||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||||
@@ -795,31 +666,6 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
|||||||
manager.unlockESIM()
|
manager.unlockESIM()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
refreshRequested := !nativeQMI
|
|
||||||
if nativeQMI {
|
|
||||||
refreshContext, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
||||||
refreshRequested, err = channel.registerProfileRefresh(refreshContext)
|
|
||||||
cancelRefresh()
|
|
||||||
if err != nil {
|
|
||||||
channel.close(context.Background())
|
|
||||||
manager.unlockESIM()
|
|
||||||
return fmt.Errorf("esim: register QMI UIM refresh: %w", err)
|
|
||||||
}
|
|
||||||
// After a refresh=true attempt reports catBusy, retry without asking the
|
|
||||||
// eUICC to start another REFRESH proactive command. SGP.22 permits the
|
|
||||||
// card to terminate the pre-existing proactive session in this mode; the
|
|
||||||
// native-QMI recovery below performs the required SIM reset and cache
|
|
||||||
// reload on behalf of the device.
|
|
||||||
if attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int); attempt > 0 {
|
|
||||||
refreshRequested = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
der, err := buildEnableProfileRequestWithRefresh(iccid, refreshRequested)
|
|
||||||
if err != nil {
|
|
||||||
channel.close(context.Background())
|
|
||||||
manager.unlockESIM()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnableProfile request (SGP.22 ES10c, per lpac):
|
// EnableProfile request (SGP.22 ES10c, per lpac):
|
||||||
// BF31 { A0 { 5A <iccid bcd> } 81 01 FF } (refresh = yes)
|
// BF31 { A0 { 5A <iccid bcd> } 81 01 FF } (refresh = yes)
|
||||||
@@ -829,38 +675,10 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
|||||||
// stays a sibling of A0, directly under BF31.
|
// stays a sibling of A0, directly under BF31.
|
||||||
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
|
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
|
||||||
// disconnect or reverse-proxy timeout must not cancel it halfway through and
|
// disconnect or reverse-proxy timeout must not cancel it halfway through and
|
||||||
// skip post-commit recovery; EC20 may otherwise remain in SIM failure
|
// skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13).
|
||||||
// (+CME 13).
|
|
||||||
commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
|
commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
|
||||||
payload, err := channel.es10(commitContext, der)
|
payload, err := channel.es10(commitContext, der)
|
||||||
cancelCommit()
|
cancelCommit()
|
||||||
// A rejected EnableProfile (for example CAT busy) does not emit REFRESH.
|
|
||||||
// Parse the card-level result before waiting for an indication, otherwise
|
|
||||||
// every retry needlessly waits for the refresh timeout.
|
|
||||||
resultBeforeClose, resultPresentBeforeClose := enableProfileResult(payload)
|
|
||||||
if err == nil && resultPresentBeforeClose && byte(resultBeforeClose) == 5 && nativeQMI {
|
|
||||||
// Registering CAT2 may immediately deliver a proactive command that was
|
|
||||||
// already pending before EnableProfile. Drain it on catBusy so the raw
|
|
||||||
// REFRESH command receives its terminal response before the retry.
|
|
||||||
catContext, cancelCAT := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
_ = channel.completeProfileRefresh(catContext)
|
|
||||||
cancelCAT()
|
|
||||||
if attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int); attempt == 0 {
|
|
||||||
recoveryContext, cancelRecovery := context.WithTimeout(context.Background(), 12*time.Second)
|
|
||||||
_ = channel.recoverCATBusy(recoveryContext)
|
|
||||||
cancelRecovery()
|
|
||||||
}
|
|
||||||
ackContext, cancelAck := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
_ = channel.acknowledgeProfileRefresh(ackContext)
|
|
||||||
cancelAck()
|
|
||||||
}
|
|
||||||
if err == nil && resultPresentBeforeClose &&
|
|
||||||
enableProfileResponseError(byte(resultBeforeClose), payload) == nil &&
|
|
||||||
refreshRequested && nativeQMI {
|
|
||||||
refreshContext, cancelRefresh := context.WithTimeout(context.Background(), 20*time.Second)
|
|
||||||
_ = channel.completeProfileRefresh(refreshContext)
|
|
||||||
cancelRefresh()
|
|
||||||
}
|
|
||||||
// Release the logical channel before any reset: openEuicc's csim holds
|
// Release the logical channel before any reset: openEuicc's csim holds
|
||||||
// opMu only for the duration of each APDU, so by here the lock is free.
|
// opMu only for the duration of each APDU, so by here the lock is free.
|
||||||
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
|
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
|
||||||
@@ -885,48 +703,10 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
|||||||
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||||
}
|
}
|
||||||
if err := enableProfileResponseError(byte(result), payload); err != nil {
|
if err := enableProfileResponseError(byte(result), payload); err != nil {
|
||||||
if errors.Is(err, ErrESIMEnableCATBusy) {
|
|
||||||
attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int)
|
|
||||||
if attempt < 11 {
|
|
||||||
manager.unlockESIM()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
return manager.ESIMSwitchProfile(context.WithValue(ctx, esimCATBusyRetryKey{}, attempt+1), id, iccid, aidHex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
manager.unlockESIM()
|
manager.unlockESIM()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
manager.markCachedProfileEnabled(id, iccid)
|
manager.markCachedProfileEnabled(id, iccid)
|
||||||
// EnableProfile already requested an eUICC REFRESH. Some AT modems consume
|
|
||||||
// that proactive command and expose the new subscription immediately, so a
|
|
||||||
// full CFUN=1,1 reset would only add downtime. Give those devices a short
|
|
||||||
// chance to prove that their SIM cache is current; modems that keep reporting
|
|
||||||
// the old ICCID continue through the established reboot/recovery path below.
|
|
||||||
if manager.canVerifyProfileSwitchWithoutRestart(id) {
|
|
||||||
probeContext, cancelProbe := context.WithTimeout(
|
|
||||||
context.WithoutCancel(ctx),
|
|
||||||
profileSwitchRefreshProbeTimeout(manager),
|
|
||||||
)
|
|
||||||
probeErr := manager.verifySwitchedICCIDAttempts(probeContext, id, iccid, 3, time.Second)
|
|
||||||
cancelProbe()
|
|
||||||
if probeErr == nil {
|
|
||||||
// Repopulate the cached snapshot while the AT transport is still live.
|
|
||||||
// Verification above is authoritative, so snapshot refresh remains
|
|
||||||
// best-effort just as it is after the legacy reboot path.
|
|
||||||
refreshContext, cancelRefresh := context.WithTimeout(
|
|
||||||
context.WithoutCancel(ctx),
|
|
||||||
manager.longTimeout,
|
|
||||||
)
|
|
||||||
_, _ = manager.Refresh(refreshContext, id)
|
|
||||||
cancelRefresh()
|
|
||||||
manager.unlockESIM()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The eUICC accepted the target profile. Reset and repopulate the modem in
|
// The eUICC accepted the target profile. Reset and repopulate the modem in
|
||||||
// a detached recovery so it survives an HTTP disconnect, but keep this API
|
// a detached recovery so it survives an HTTP disconnect, but keep this API
|
||||||
// call pending until the live modem ICCID proves that the switch took effect.
|
// call pending until the live modem ICCID proves that the switch took effect.
|
||||||
@@ -941,8 +721,6 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
|||||||
return manager.verifySwitchedICCID(verifyContext, id, iccid)
|
return manager.verifySwitchedICCID(verifyContext, id, iccid)
|
||||||
}
|
}
|
||||||
|
|
||||||
type esimCATBusyRetryKey struct{}
|
|
||||||
|
|
||||||
func (manager *Manager) startProfileSwitchRecovery(id string) {
|
func (manager *Manager) startProfileSwitchRecovery(id string) {
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
manager.esimRecoveryMu.Lock()
|
manager.esimRecoveryMu.Lock()
|
||||||
@@ -1075,18 +853,6 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
|||||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
||||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
||||||
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
|
||||||
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
|
|
||||||
cancelReset()
|
|
||||||
if err == nil {
|
|
||||||
time.Sleep(1500 * time.Millisecond)
|
|
||||||
}
|
|
||||||
// Native WWAN identity and profile verification are both QMI-backed.
|
|
||||||
// Do not enter the AT refresh path: OpenStick firmware can accept the
|
|
||||||
// switch while timing out every EC20-specific AT identity command.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cancelReset()
|
|
||||||
if !manager.isPCSCDevice(id) {
|
if !manager.isPCSCDevice(id) {
|
||||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||||
@@ -1209,69 +975,17 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
|
|||||||
return timeout
|
return timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
func profileSwitchRefreshProbeTimeout(manager *Manager) time.Duration {
|
|
||||||
// Allow both standard ICCID commands to consume one ordinary command
|
|
||||||
// timeout, plus a small window for the eUICC REFRESH to settle. Keep the
|
|
||||||
// optimisation bounded so an older modem reaches its required reboot soon.
|
|
||||||
timeout := manager.commandTimeout*2 + time.Second
|
|
||||||
if timeout < 3*time.Second {
|
|
||||||
return 3 * time.Second
|
|
||||||
}
|
|
||||||
if timeout > 10*time.Second {
|
|
||||||
return 10 * time.Second
|
|
||||||
}
|
|
||||||
return timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
|
|
||||||
_, native, err := manager.nativeQMIControl(id)
|
|
||||||
return err == nil && !native && !manager.isPCSCDevice(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
|
// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
|
||||||
// result of zero only means the eUICC accepted the operation; the state change
|
// result of zero only means the eUICC accepted the operation; the state change
|
||||||
// is finalized by REFRESH/reset. The UI must not report success until the modem
|
// is finalized by REFRESH/reset. The UI must not report success until the modem
|
||||||
// is actually exposing the requested ICCID.
|
// is actually exposing the requested ICCID.
|
||||||
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
|
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
|
||||||
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) verifySwitchedICCIDAttempts(
|
|
||||||
ctx context.Context,
|
|
||||||
id string,
|
|
||||||
expected string,
|
|
||||||
attempts int,
|
|
||||||
interval time.Duration,
|
|
||||||
) error {
|
|
||||||
expected = strings.TrimSpace(expected)
|
expected = strings.TrimSpace(expected)
|
||||||
|
const attempts = 6
|
||||||
var lastICCID string
|
var lastICCID string
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
if control, native, nativeErr := manager.nativeQMIControl(id); native {
|
if manager.isPCSCDevice(id) {
|
||||||
if nativeErr != nil {
|
|
||||||
lastErr = nativeErr
|
|
||||||
} else {
|
|
||||||
state, lookupErr := manager.lookup(id)
|
|
||||||
if lookupErr != nil {
|
|
||||||
lastErr = lookupErr
|
|
||||||
} else {
|
|
||||||
candidate := manager.candidateFor(state)
|
|
||||||
candidate.QMIControl = control
|
|
||||||
live, readErr := manager.readNativeQMIICCID(ctx, candidate)
|
|
||||||
if readErr == nil {
|
|
||||||
lastICCID = strings.TrimSpace(live)
|
|
||||||
if lastICCID == expected {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
lastErr = fmt.Errorf("native QMI still reports ICCID %s", lastICCID)
|
|
||||||
} else {
|
|
||||||
lastErr = readErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if nativeErr != nil {
|
|
||||||
lastErr = nativeErr
|
|
||||||
} else if manager.isPCSCDevice(id) {
|
|
||||||
snapshot, err := manager.Refresh(ctx, id)
|
snapshot, err := manager.Refresh(ctx, id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
lastICCID = strings.TrimSpace(snapshot.ICCID)
|
lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||||
@@ -1305,7 +1019,7 @@ func (manager *Manager) verifySwitchedICCIDAttempts(
|
|||||||
}
|
}
|
||||||
if attempt+1 < attempts {
|
if attempt+1 < attempts {
|
||||||
select {
|
select {
|
||||||
case <-time.After(interval):
|
case <-time.After(2 * time.Second):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
|
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package device
|
package device
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -207,38 +206,6 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
|
|||||||
client.assertDone(t)
|
client.assertDone(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
|
||||||
const target = "89492026266006792824"
|
|
||||||
client := &transcriptClient{steps: []clientStep{
|
|
||||||
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
|
|
||||||
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
|
||||||
}}
|
|
||||||
manager, id := newStartedTestManager(t, client)
|
|
||||||
if !manager.canVerifyProfileSwitchWithoutRestart(id) {
|
|
||||||
t.Fatal("AT modem should be eligible for refresh verification before restart")
|
|
||||||
}
|
|
||||||
if err := manager.verifySwitchedICCIDAttempts(context.Background(), id, target, 2, 0); err != nil {
|
|
||||||
t.Fatalf("verifySwitchedICCIDAttempts: %v", err)
|
|
||||||
}
|
|
||||||
client.assertDone(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProfileSwitchRefreshProbeTimeoutIsBounded(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
command time.Duration
|
|
||||||
want time.Duration
|
|
||||||
}{
|
|
||||||
{command: 100 * time.Millisecond, want: 3 * time.Second},
|
|
||||||
{command: 3 * time.Second, want: 7 * time.Second},
|
|
||||||
{command: 30 * time.Second, want: 10 * time.Second},
|
|
||||||
} {
|
|
||||||
manager := &Manager{commandTimeout: test.command}
|
|
||||||
if got := profileSwitchRefreshProbeTimeout(manager); got != test.want {
|
|
||||||
t.Fatalf("command timeout %s: probe timeout = %s, want %s", test.command, got, test.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEUMManufacturerForWatchData(t *testing.T) {
|
func TestEUMManufacturerForWatchData(t *testing.T) {
|
||||||
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
|
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
|
||||||
t.Fatalf("manufacturer = %q", got)
|
t.Fatalf("manufacturer = %q", got)
|
||||||
@@ -316,41 +283,6 @@ func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
|
|||||||
client.assertDone(t)
|
client.assertDone(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNativeQMIUsesUIMLogicalChannelForEUICC(t *testing.T) {
|
|
||||||
manager, _, id := newStartedNativeQMITestManager(t)
|
|
||||||
if err := manager.SetBackend(id, "qmi"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
session := &fakeQMIRadioSession{
|
|
||||||
openChannel: 3,
|
|
||||||
apduResponse: []byte{0xDE, 0xAD, 0x90, 0x00},
|
|
||||||
}
|
|
||||||
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
|
||||||
return session, nil
|
|
||||||
}
|
|
||||||
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open QMI eUICC: %v", err)
|
|
||||||
}
|
|
||||||
payload, sw, err := channel.transmit(context.Background(), []byte{0x80, 0xCA, 0x00, 0x00, 0x00}, 0x80)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("transmit QMI APDU: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(payload, []byte{0xDE, 0xAD}) || sw != 0x9000 {
|
|
||||||
t.Fatalf("QMI APDU response = %X/%04X", payload, sw)
|
|
||||||
}
|
|
||||||
channel.close(context.Background())
|
|
||||||
if len(session.openedAIDs) != 1 || strings.ToUpper(hex.EncodeToString(session.openedAIDs[0])) != isdRAID {
|
|
||||||
t.Fatalf("opened AIDs = %X", session.openedAIDs)
|
|
||||||
}
|
|
||||||
if len(session.apdus) != 1 || session.apdus[0][0] != 0x83 {
|
|
||||||
t.Fatalf("QMI APDUs = %X", session.apdus)
|
|
||||||
}
|
|
||||||
if len(session.closedChannels) != 1 || session.closedChannels[0] != 3 || session.closeCount != 1 {
|
|
||||||
t.Fatalf("closed channels/session = %v/%d", session.closedChannels, session.closeCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
||||||
cause := &modem.CommandError{
|
cause := &modem.CommandError{
|
||||||
Command: `AT+CSIM=10,"0070000001"`,
|
Command: `AT+CSIM=10,"0070000001"`,
|
||||||
|
|||||||
@@ -1,813 +0,0 @@
|
|||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
|
|
||||||
type nativeQMIIMEISession interface {
|
|
||||||
GetIMEI(context.Context) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type nativeQMIEuiccSession interface {
|
|
||||||
qmiRadioSession
|
|
||||||
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
|
|
||||||
CloseLogicalChannel(context.Context, uint8, uint8) error
|
|
||||||
SendAPDU(context.Context, uint8, uint8, []byte) ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// nativeQMIRefreshSession is implemented by production QMI sessions that can
|
|
||||||
// participate in the modem's UIM REFRESH state machine. Keep it separate from
|
|
||||||
// nativeQMIEuiccSession so transcript fakes and older QMI implementations can
|
|
||||||
// continue to use the APDU transport without pretending to handle indications.
|
|
||||||
type nativeQMIRefreshSession interface {
|
|
||||||
RegisterUIMRefresh(context.Context) error
|
|
||||||
CompleteUIMRefresh(context.Context) error
|
|
||||||
AcknowledgeUIMRefresh(context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type nativeQMIUIMResetSession interface {
|
|
||||||
ResetUIM(context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type nativeQMIVoWiFiSession interface {
|
|
||||||
qmiRadioSession
|
|
||||||
GetICCID(context.Context) (string, error)
|
|
||||||
GetIMEI(context.Context) (string, error)
|
|
||||||
GetIMSI(context.Context) (string, error)
|
|
||||||
GetNativeMCCMNC(context.Context) (string, string, error)
|
|
||||||
GetUSIMAID(context.Context) ([]byte, error)
|
|
||||||
GetISIMAID(context.Context) ([]byte, error)
|
|
||||||
GetServingSystem(context.Context) (*qmi.ServingSystem, error)
|
|
||||||
AttachDetach(context.Context, bool) error
|
|
||||||
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
|
|
||||||
CloseLogicalChannel(context.Context, uint8, uint8) error
|
|
||||||
SendAPDU(context.Context, uint8, uint8, []byte) ([]byte, error)
|
|
||||||
PowerOffSIM(context.Context, uint8) error
|
|
||||||
PowerOnSIM(context.Context, uint8) 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
|
|
||||||
catID uint8
|
|
||||||
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) {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return uim.GetICCID(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) GetIMSI(ctx context.Context) (string, error) {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return uim.GetIMSI(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) GetNativeMCCMNC(ctx context.Context) (string, string, error) {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return uim.GetNativeMCCMNC(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) GetUSIMAID(ctx context.Context) ([]byte, error) {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return uim.GetUSIMAID(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) GetISIMAID(ctx context.Context) ([]byte, error) {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return uim.GetISIMAID(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) PowerOffSIM(ctx context.Context, slot uint8) error {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return uim.PowerOffSIM(ctx, slot)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) PowerOnSIM(ctx context.Context, slot uint8) error {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return uim.PowerOnSIM(ctx, slot)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) ResetUIM(ctx context.Context) error {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return uim.Reset(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) uimService(ctx context.Context) (*qmi.UIMService, error) {
|
|
||||||
if session == nil || session.client == nil {
|
|
||||||
return nil, 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 nil, err
|
|
||||||
}
|
|
||||||
session.uim = uim
|
|
||||||
}
|
|
||||||
return session.uim, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) OpenLogicalChannel(ctx context.Context, slot uint8, aid []byte) (byte, error) {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return uim.OpenLogicalChannel(ctx, slot, aid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) CloseLogicalChannel(ctx context.Context, slot, channel uint8) error {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return uim.CloseLogicalChannel(ctx, slot, channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) SendAPDU(ctx context.Context, slot, channel uint8, command []byte) ([]byte, error) {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return uim.SendAPDU(ctx, slot, channel, command)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterUIMRefresh mirrors the terminal registration used by libqmi for a
|
|
||||||
// physical card slot. EnableProfile(refresh=true) may cause the eUICC to issue
|
|
||||||
// a proactive REFRESH; without a registered terminal the card remains CAT busy
|
|
||||||
// after the profile has changed and rejects the next profile operation.
|
|
||||||
func (session *productionQMIRadioSession) RegisterUIMRefresh(ctx context.Context) error {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := uim.RefreshRegisterAll(ctx, qmi.UIMRefreshRegisterAllRequest{
|
|
||||||
SessionType: qmi.UIMSessionTypeCardSlot1,
|
|
||||||
RegisterFlag: true,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if session.catID == 0 {
|
|
||||||
clientID, err := session.client.AllocateClientIDWithContext(ctx, qmi.ServiceCAT2)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("allocate QMI CAT2 client: %w", err)
|
|
||||||
}
|
|
||||||
session.catID = clientID
|
|
||||||
}
|
|
||||||
configuration, configErr := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x002E, nil)
|
|
||||||
if configErr == nil && configuration.CheckResult() == nil {
|
|
||||||
if modeTLV := qmi.FindTLV(configuration.TLVs, 0x10); modeTLV != nil && len(modeTLV.Value) > 0 {
|
|
||||||
slog.Info("QMI CAT2 configuration", "mode", modeTLV.Value[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response, err := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x0001, []qmi.TLV{
|
|
||||||
// Claim the raw proactive-command events implemented by this CAT2
|
|
||||||
// generation (bits 0..22 and 24..25). A profile can leave any STK
|
|
||||||
// command pending, not only REFRESH, and SGP.22 forbids profile changes
|
|
||||||
// while that proactive session is unanswered.
|
|
||||||
{Type: 0x10, Value: []byte{0xFF, 0xFF, 0x7F, 0x03}},
|
|
||||||
// Slot mask bit 0 selects slot 1.
|
|
||||||
{Type: 0x12, Value: []byte{0x01}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("register QMI CAT2 refresh: %w", err)
|
|
||||||
}
|
|
||||||
if err := response.CheckResult(); err != nil {
|
|
||||||
return fmt.Errorf("register QMI CAT2 refresh: %w", err)
|
|
||||||
}
|
|
||||||
for _, tlv := range response.TLVs {
|
|
||||||
if tlv.Type >= 0x10 && tlv.Type <= 0x12 {
|
|
||||||
slog.Info("QMI CAT2 registration response", "tlv", fmt.Sprintf("0x%02X", tlv.Type), "value", fmt.Sprintf("%X", tlv.Value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompleteUIMRefresh consumes refresh indications on the same QMI client that
|
|
||||||
// registered for them. Qualcomm requires RefreshComplete only for START
|
|
||||||
// indications whose mode is not RESET; RESET is completed by the modem itself.
|
|
||||||
func (session *productionQMIRadioSession) CompleteUIMRefresh(ctx context.Context) error {
|
|
||||||
if session == nil || session.client == nil {
|
|
||||||
return errors.New("QMI UIM refresh session is unavailable")
|
|
||||||
}
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
refreshCompleted := false
|
|
||||||
uimEnded := false
|
|
||||||
catEnded := false
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Some firmware handles a RESET internally and never forwards an
|
|
||||||
// indication to this client. A missing indication is therefore not
|
|
||||||
// a failed profile commit.
|
|
||||||
return nil
|
|
||||||
case event, ok := <-session.client.Events():
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if event.ServiceID == qmi.ServiceCAT2 && event.MessageID == 0x0001 {
|
|
||||||
for _, eventTLV := range event.Packet.TLVs {
|
|
||||||
slog.Info("QMI CAT2 event", "tlv", fmt.Sprintf("0x%02X", eventTLV.Type), "length", len(eventTLV.Value))
|
|
||||||
}
|
|
||||||
if tlv := qmi.FindTLV(event.Packet.TLVs, 0x19); tlv != nil && len(tlv.Value) >= 4 {
|
|
||||||
mode := uint16(tlv.Value[0]) | uint16(tlv.Value[1])<<8
|
|
||||||
stage := uint16(tlv.Value[2]) | uint16(tlv.Value[3])<<8
|
|
||||||
slog.Info("QMI CAT2 profile refresh", "stage", stage, "mode", mode)
|
|
||||||
if stage == 3 {
|
|
||||||
return errors.New("QMI CAT2 refresh ended with failure")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// UIM refresh completion is not a CAT terminal response. Qualcomm
|
|
||||||
// delivers the raw proactive command in a command-specific TLV; send
|
|
||||||
// a response carrying that command's reference ID. Unsupported UI STK
|
|
||||||
// commands receive the standards-defined "beyond terminal
|
|
||||||
// capabilities" result, which still closes the proactive session.
|
|
||||||
for _, commandTLV := range event.Packet.TLVs {
|
|
||||||
if !isRawCATCommandTLV(commandTLV.Type) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ref, terminalResponse, commandType, responseOK := catProactiveTerminalResponse(commandTLV.Value)
|
|
||||||
if !responseOK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := session.sendCATTerminalResponse(ctx, ref, terminalResponse); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Info("QMI CAT2 terminal response sent", "reference", ref, "command", fmt.Sprintf("0x%02X", commandType))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if tlv := qmi.FindTLV(event.Packet.TLVs, 0x1A); tlv != nil && len(tlv.Value) > 0 {
|
|
||||||
// Older MDM8916 CAT2 firmware encodes this enum in one byte;
|
|
||||||
// newer interface descriptions model it as a 32-bit value.
|
|
||||||
reason := uint32(tlv.Value[0])
|
|
||||||
if len(tlv.Value) >= 4 {
|
|
||||||
reason |= uint32(tlv.Value[1])<<8 | uint32(tlv.Value[2])<<16 | uint32(tlv.Value[3])<<24
|
|
||||||
}
|
|
||||||
slog.Info("QMI CAT2 proactive session ended", "reason", reason)
|
|
||||||
catEnded = true
|
|
||||||
if uimEnded {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if event.Type != qmi.EventUIMRefresh {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, parseErr := qmi.ParseUIMRefreshIndication(event.Packet)
|
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
const (
|
|
||||||
refreshStageWaitForOK = uint8(0)
|
|
||||||
refreshStageStart = uint8(1)
|
|
||||||
refreshStageSuccess = uint8(2)
|
|
||||||
refreshStageFailure = uint8(3)
|
|
||||||
refreshModeReset = uint8(0)
|
|
||||||
)
|
|
||||||
slog.Info("QMI UIM profile refresh", "stage", info.Stage, "mode", info.Mode)
|
|
||||||
switch info.Stage {
|
|
||||||
case refreshStageWaitForOK:
|
|
||||||
// Registration without a vote advances on its own. Keep the UIM
|
|
||||||
// client alive for the subsequent START and END indications.
|
|
||||||
continue
|
|
||||||
case refreshStageStart:
|
|
||||||
if info.Mode == refreshModeReset || refreshCompleted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// libqmi intentionally uses CARD_SLOT_1 here rather than echoing
|
|
||||||
// the provisioning session from the indication.
|
|
||||||
_ = uim.RefreshComplete(ctx, qmi.UIMRefreshCompleteRequest{
|
|
||||||
SessionType: qmi.UIMSessionTypeCardSlot1,
|
|
||||||
RefreshSuccess: true,
|
|
||||||
})
|
|
||||||
refreshCompleted = true
|
|
||||||
continue
|
|
||||||
case refreshStageSuccess:
|
|
||||||
uimEnded = true
|
|
||||||
if catEnded {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
case refreshStageFailure:
|
|
||||||
return errors.New("QMI UIM refresh ended with failure")
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) sendCATTerminalResponse(ctx context.Context, reference uint32, terminalResponse []byte) error {
|
|
||||||
value := make([]byte, 0, 6+len(terminalResponse))
|
|
||||||
value = binary.LittleEndian.AppendUint32(value, reference)
|
|
||||||
value = binary.LittleEndian.AppendUint16(value, uint16(len(terminalResponse)))
|
|
||||||
value = append(value, terminalResponse...)
|
|
||||||
response, err := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x0021, []qmi.TLV{
|
|
||||||
{Type: 0x01, Value: value},
|
|
||||||
{Type: 0x10, Value: []byte{0x01}}, // CAT slot 1 (not a slot mask)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("send QMI CAT2 refresh terminal response: %w", err)
|
|
||||||
}
|
|
||||||
if err := response.CheckResult(); err != nil {
|
|
||||||
return fmt.Errorf("send QMI CAT2 refresh terminal response: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// catProactiveTerminalResponse extracts a raw CAT command carried as
|
|
||||||
// {reference:uint32LE, length:uint16LE, BER-TLV command} and creates the
|
|
||||||
// standards-shaped terminal response. VoCat has no interactive STK UI, so
|
|
||||||
// commands other than REFRESH/MORE TIME are explicitly reported unsupported.
|
|
||||||
func catProactiveTerminalResponse(raw []byte) (uint32, []byte, byte, bool) {
|
|
||||||
if len(raw) < 8 {
|
|
||||||
return 0, nil, 0, false
|
|
||||||
}
|
|
||||||
reference := binary.LittleEndian.Uint32(raw[:4])
|
|
||||||
commandLength := int(binary.LittleEndian.Uint16(raw[4:6]))
|
|
||||||
if commandLength <= 0 || commandLength > len(raw)-6 {
|
|
||||||
return 0, nil, 0, false
|
|
||||||
}
|
|
||||||
command := raw[6 : 6+commandLength]
|
|
||||||
if len(command) < 2 || command[0] != 0xD0 {
|
|
||||||
return 0, nil, 0, false
|
|
||||||
}
|
|
||||||
bodyLength, lengthBytes, ok := catBERLength(command[1:])
|
|
||||||
if !ok || 1+lengthBytes+bodyLength > len(command) {
|
|
||||||
return 0, nil, 0, false
|
|
||||||
}
|
|
||||||
body := command[1+lengthBytes : 1+lengthBytes+bodyLength]
|
|
||||||
for offset := 0; offset < len(body); {
|
|
||||||
tag := body[offset]
|
|
||||||
offset++
|
|
||||||
length, consumed, ok := catBERLength(body[offset:])
|
|
||||||
if !ok || offset+consumed+length > len(body) {
|
|
||||||
return 0, nil, 0, false
|
|
||||||
}
|
|
||||||
offset += consumed
|
|
||||||
value := body[offset : offset+length]
|
|
||||||
offset += length
|
|
||||||
if tag&0x7F != 0x01 || len(value) < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result := byte(0x30) // command beyond terminal capabilities
|
|
||||||
if value[1] == 0x01 || value[1] == 0x02 { // REFRESH or MORE TIME
|
|
||||||
result = 0x00 // command performed successfully
|
|
||||||
}
|
|
||||||
terminalResponse := []byte{
|
|
||||||
0x81, 0x03, value[0], value[1], value[2], // command details
|
|
||||||
0x82, 0x02, 0x82, 0x81, // terminal -> UICC
|
|
||||||
0x83, 0x01, result,
|
|
||||||
}
|
|
||||||
return reference, terminalResponse, value[1], true
|
|
||||||
}
|
|
||||||
return 0, nil, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func catRefreshTerminalResponse(raw []byte) (uint32, []byte, bool) {
|
|
||||||
reference, response, commandType, ok := catProactiveTerminalResponse(raw)
|
|
||||||
return reference, response, ok && commandType == 0x01
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRawCATCommandTLV(tag byte) bool {
|
|
||||||
switch tag {
|
|
||||||
case 0x10, 0x11, 0x12, 0x13, 0x14, 0x17, 0x18,
|
|
||||||
0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F,
|
|
||||||
0x51, 0x52, 0x53, 0x54, 0x66, 0x6A:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func catBERLength(raw []byte) (length int, consumed int, ok bool) {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
switch raw[0] {
|
|
||||||
case 0x81:
|
|
||||||
if len(raw) < 2 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
return int(raw[1]), 2, true
|
|
||||||
case 0x82:
|
|
||||||
if len(raw) < 3 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
return int(raw[1])<<8 | int(raw[2]), 3, true
|
|
||||||
default:
|
|
||||||
if raw[0]&0x80 != 0 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
return int(raw[0]), 1, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AcknowledgeUIMRefresh is a recovery vote for a refresh that predates this
|
|
||||||
// QMI client. Qualcomm documents RefreshComplete as harmless when no vote is
|
|
||||||
// pending; it lets a newly started service release a stale CAT-busy condition
|
|
||||||
// left by an interrupted LPA/terminal transaction.
|
|
||||||
func (session *productionQMIRadioSession) AcknowledgeUIMRefresh(ctx context.Context) error {
|
|
||||||
uim, err := session.uimService(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return uim.RefreshComplete(ctx, qmi.UIMRefreshCompleteRequest{
|
|
||||||
SessionType: qmi.UIMSessionTypeCardSlot1,
|
|
||||||
RefreshSuccess: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *productionQMIRadioSession) GetIMEI(ctx context.Context) (string, error) {
|
|
||||||
if session == nil || session.dms == nil {
|
|
||||||
return "", errors.New("QMI DMS identity session is unavailable")
|
|
||||||
}
|
|
||||||
info, err := session.dms.GetDeviceSerialNumbers(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return info.IMEI, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 && session.catID != 0 {
|
|
||||||
closeErrors = append(closeErrors, session.client.ReleaseClientID(qmi.ServiceCAT2, session.catID))
|
|
||||||
session.catID = 0
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCATRefreshTerminalResponse(t *testing.T) {
|
|
||||||
raw := []byte{
|
|
||||||
0x44, 0x33, 0x22, 0x11, // reference
|
|
||||||
0x0B, 0x00, // command length
|
|
||||||
0xD0, 0x09,
|
|
||||||
0x81, 0x03, 0x07, 0x01, 0x00,
|
|
||||||
0x82, 0x02, 0x81, 0x82,
|
|
||||||
}
|
|
||||||
reference, response, ok := catRefreshTerminalResponse(raw)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("catRefreshTerminalResponse() did not recognize REFRESH")
|
|
||||||
}
|
|
||||||
if reference != 0x11223344 {
|
|
||||||
t.Fatalf("reference = 0x%08X", reference)
|
|
||||||
}
|
|
||||||
want := []byte{
|
|
||||||
0x81, 0x03, 0x07, 0x01, 0x00,
|
|
||||||
0x82, 0x02, 0x82, 0x81,
|
|
||||||
0x83, 0x01, 0x00,
|
|
||||||
}
|
|
||||||
if !bytes.Equal(response, want) {
|
|
||||||
t.Fatalf("response = % X, want % X", response, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCATRefreshTerminalResponseRejectsOtherCommands(t *testing.T) {
|
|
||||||
raw := []byte{
|
|
||||||
0x01, 0x00, 0x00, 0x00,
|
|
||||||
0x0B, 0x00,
|
|
||||||
0xD0, 0x09,
|
|
||||||
0x81, 0x03, 0x01, 0x21, 0x00, // DISPLAY TEXT
|
|
||||||
0x82, 0x02, 0x81, 0x02,
|
|
||||||
}
|
|
||||||
if _, _, ok := catRefreshTerminalResponse(raw); ok {
|
|
||||||
t.Fatal("catRefreshTerminalResponse() accepted a non-REFRESH command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCATRefreshTerminalResponseSupportsLongBERLength(t *testing.T) {
|
|
||||||
command := []byte{0xD0, 0x81, 0x09, 0x81, 0x03, 0x02, 0x01, 0x01, 0x82, 0x02, 0x81, 0x82}
|
|
||||||
raw := append([]byte{0x02, 0x00, 0x00, 0x00, byte(len(command)), 0x00}, command...)
|
|
||||||
if _, _, ok := catRefreshTerminalResponse(raw); !ok {
|
|
||||||
t.Fatal("catRefreshTerminalResponse() rejected 0x81 BER length")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) readNativeQMIIMEI(ctx context.Context, candidate modem.Candidate) (string, error) {
|
|
||||||
if manager.qmiRadioOpener == nil {
|
|
||||||
return "", errors.New("QMI DMS IMEI reader is unavailable")
|
|
||||||
}
|
|
||||||
session, err := manager.qmiRadioOpener(ctx, candidate.QMIControl)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
reader, ok := session.(nativeQMIIMEISession)
|
|
||||||
if !ok {
|
|
||||||
return "", errors.New("QMI session does not expose DMS IMEI reading")
|
|
||||||
}
|
|
||||||
value, err := reader.GetIMEI(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read device serial numbers: %w", err)
|
|
||||||
}
|
|
||||||
imei := parseIdentifier(modem.Response{Lines: []string{value}}, nil, 14, 17)
|
|
||||||
if imei == "" {
|
|
||||||
return "", errors.New("QMI DMS returned an invalid IMEI")
|
|
||||||
}
|
|
||||||
return imei, nil
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"vocat/internal/modem"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxHardwareErrorDetail = 1024
|
|
||||||
|
|
||||||
var longHexPayload = regexp.MustCompile(`(?i)\b[0-9a-f]{48,}\b`)
|
|
||||||
|
|
||||||
// HardwareErrorDetail returns a diagnostic error suitable for persistent and
|
|
||||||
// browser-visible logs. AT payloads can contain APDU authentication material,
|
|
||||||
// SMS data, or APN credentials, so CommandError values retain only the command
|
|
||||||
// name and modem final result. Very long hexadecimal payloads from wrapped
|
|
||||||
// protocol errors are removed as a second line of defence.
|
|
||||||
func HardwareErrorDetail(err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
detail := redactCommandErrors(err.Error(), err)
|
|
||||||
detail = longHexPayload.ReplaceAllString(detail, "[redacted hex payload]")
|
|
||||||
detail = strings.Map(func(character rune) rune {
|
|
||||||
if unicode.IsControl(character) && character != '\t' && character != '\n' {
|
|
||||||
return ' '
|
|
||||||
}
|
|
||||||
return character
|
|
||||||
}, strings.TrimSpace(detail))
|
|
||||||
runes := []rune(detail)
|
|
||||||
if len(runes) > maxHardwareErrorDetail {
|
|
||||||
detail = string(runes[:maxHardwareErrorDetail]) + "..."
|
|
||||||
}
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
|
|
||||||
func redactCommandErrors(detail string, err error) string {
|
|
||||||
if commandErr, ok := err.(*modem.CommandError); ok {
|
|
||||||
detail = strings.ReplaceAll(detail, commandErr.Error(), safeCommandError(commandErr))
|
|
||||||
}
|
|
||||||
switch wrapped := err.(type) {
|
|
||||||
case interface{ Unwrap() []error }:
|
|
||||||
for _, child := range wrapped.Unwrap() {
|
|
||||||
detail = redactCommandErrors(detail, child)
|
|
||||||
}
|
|
||||||
case interface{ Unwrap() error }:
|
|
||||||
if child := wrapped.Unwrap(); child != nil {
|
|
||||||
detail = redactCommandErrors(detail, child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeCommandError(err *modem.CommandError) string {
|
|
||||||
command := safeATCommandName(err.Command)
|
|
||||||
final := strings.TrimSpace(err.Final)
|
|
||||||
if final == "" {
|
|
||||||
final = "unknown modem error"
|
|
||||||
}
|
|
||||||
return command + " failed: " + final
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeATCommandName(command string) string {
|
|
||||||
command = strings.ToUpper(strings.TrimSpace(command))
|
|
||||||
if command == "" {
|
|
||||||
return "AT command"
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(command, "ATD") {
|
|
||||||
return "ATD"
|
|
||||||
}
|
|
||||||
for index, character := range command {
|
|
||||||
if character == '=' || character == '?' || character == ',' ||
|
|
||||||
character == '"' || unicode.IsSpace(character) {
|
|
||||||
command = command[:index]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(command, "AT") || len(command) > 32 {
|
|
||||||
return "AT command"
|
|
||||||
}
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"vocat/internal/loghub"
|
|
||||||
"vocat/internal/modem"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHardwareErrorDetailRedactsATPayload(t *testing.T) {
|
|
||||||
const payload = "00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"
|
|
||||||
commandErr := &modem.CommandError{
|
|
||||||
Command: `AT+CSIM=78,"` + payload + `"`,
|
|
||||||
Final: "+CME ERROR: 13",
|
|
||||||
Lines: []string{payload},
|
|
||||||
}
|
|
||||||
err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), commandErr))
|
|
||||||
detail := HardwareErrorDetail(err)
|
|
||||||
if strings.Contains(detail, payload) || strings.Contains(detail, "AT+CSIM=") {
|
|
||||||
t.Fatalf("hardware error exposed AT payload: %q", detail)
|
|
||||||
}
|
|
||||||
if !strings.Contains(detail, "select ISIM") || !strings.Contains(detail, "AT+CSIM failed: +CME ERROR: 13") {
|
|
||||||
t.Fatalf("hardware error lost useful diagnostics: %q", detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManagerLogsNewHardwareFailuresWithoutPollingSpam(t *testing.T) {
|
|
||||||
commandError := func() error {
|
|
||||||
return &modem.CommandError{Command: "AT+CSQ", Final: "+CME ERROR: 13"}
|
|
||||||
}
|
|
||||||
client := &transcriptClient{steps: []clientStep{
|
|
||||||
{command: "AT+CSQ", err: commandError()},
|
|
||||||
{command: "AT+CSQ", err: commandError()},
|
|
||||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
|
||||||
{command: "AT+CSQ", err: commandError()},
|
|
||||||
}}
|
|
||||||
manager, id := newStartedTestManager(t, client)
|
|
||||||
hub := loghub.New(nil, 100)
|
|
||||||
manager.logger = slog.New(hub)
|
|
||||||
|
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
|
||||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
|
||||||
}
|
|
||||||
if entries := hub.History(10, slog.LevelDebug, ""); len(entries) != 1 {
|
|
||||||
t.Fatalf("continuous failure produced %d log entries, want 1", len(entries))
|
|
||||||
}
|
|
||||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
|
||||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
|
||||||
|
|
||||||
entries := hub.History(10, slog.LevelDebug, "")
|
|
||||||
if len(entries) != 2 {
|
|
||||||
t.Fatalf("failure after recovery produced %d total log entries, want 2", len(entries))
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.Message != "hardware operation failed" || entry.Fields["device_id"] != id {
|
|
||||||
t.Fatalf("hardware log entry = %#v", entry)
|
|
||||||
}
|
|
||||||
if entry.Fields["error"] != "AT+CSQ failed: +CME ERROR: 13" {
|
|
||||||
t.Fatalf("hardware log detail = %#v", entry.Fields["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client.assertDone(t)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -22,7 +21,6 @@ type Options struct {
|
|||||||
SMSTimeout time.Duration
|
SMSTimeout time.Duration
|
||||||
ScanTimeout time.Duration
|
ScanTimeout time.Duration
|
||||||
CardReaders *pcsc.Service
|
CardReaders *pcsc.Service
|
||||||
Logger *slog.Logger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
@@ -40,15 +38,9 @@ type Manager struct {
|
|||||||
smsTimeout time.Duration
|
smsTimeout time.Duration
|
||||||
scanTimeout time.Duration
|
scanTimeout time.Duration
|
||||||
cardReaders *pcsc.Service
|
cardReaders *pcsc.Service
|
||||||
logger *slog.Logger
|
started bool
|
||||||
|
devices map[string]*managedDevice
|
||||||
qmiRadioOpener qmiRadioSessionOpener
|
ussdSessions map[string]ussdSession
|
||||||
nativeQMIRegistrationMu sync.Mutex
|
|
||||||
nativeQMIRegistrationInFlight map[string]struct{}
|
|
||||||
|
|
||||||
started bool
|
|
||||||
devices map[string]*managedDevice
|
|
||||||
ussdSessions map[string]ussdSession
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
|
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
|
||||||
@@ -123,11 +115,6 @@ func NewManager(options Options) (*Manager, error) {
|
|||||||
smsTimeout: options.SMSTimeout,
|
smsTimeout: options.SMSTimeout,
|
||||||
scanTimeout: options.ScanTimeout,
|
scanTimeout: options.ScanTimeout,
|
||||||
cardReaders: options.CardReaders,
|
cardReaders: options.CardReaders,
|
||||||
logger: options.Logger,
|
|
||||||
|
|
||||||
qmiRadioOpener: openQMIRadioSession,
|
|
||||||
nativeQMIRegistrationInFlight: make(map[string]struct{}),
|
|
||||||
|
|
||||||
devices: make(map[string]*managedDevice),
|
devices: make(map[string]*managedDevice),
|
||||||
ussdSessions: make(map[string]ussdSession),
|
ussdSessions: make(map[string]ussdSession),
|
||||||
esimRecoveries: make(map[string]chan struct{}),
|
esimRecoveries: make(map[string]chan struct{}),
|
||||||
@@ -245,20 +232,7 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
|||||||
state.opMu.Unlock()
|
state.opMu.Unlock()
|
||||||
}
|
}
|
||||||
manager.resetChangedClients()
|
manager.resetChangedClients()
|
||||||
|
return manager.List(), nil
|
||||||
// List retains previously discovered devices so configured hardware can be
|
|
||||||
// rendered as offline after it is unplugged. Discover, however, is a fresh
|
|
||||||
// physical scan and must only return devices that are present now. Returning
|
|
||||||
// the retained entries here allowed an unplugged modem to be selected and
|
|
||||||
// added again from the device discovery screen.
|
|
||||||
devices := manager.List()
|
|
||||||
present := devices[:0]
|
|
||||||
for _, entry := range devices {
|
|
||||||
if entry.Discovered {
|
|
||||||
present = append(present, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return present, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) resetChangedClients() {
|
func (manager *Manager) resetChangedClients() {
|
||||||
@@ -377,11 +351,10 @@ func (manager *Manager) setResult(
|
|||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
manager.mu.Lock()
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
if manager.devices[id] != state {
|
if manager.devices[id] != state {
|
||||||
manager.mu.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousError := state.lastError
|
|
||||||
if snapshot != nil {
|
if snapshot != nil {
|
||||||
value := *snapshot
|
value := *snapshot
|
||||||
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
||||||
@@ -393,19 +366,6 @@ func (manager *Manager) setResult(
|
|||||||
} else {
|
} else {
|
||||||
state.lastError = ""
|
state.lastError = ""
|
||||||
}
|
}
|
||||||
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
|
|
||||||
backend := state.backend
|
|
||||||
hardwareKind := state.candidate.HardwareKind
|
|
||||||
manager.mu.Unlock()
|
|
||||||
if shouldLog {
|
|
||||||
manager.logger.Warn(
|
|
||||||
"hardware operation failed",
|
|
||||||
"device_id", id,
|
|
||||||
"backend", backend,
|
|
||||||
"hardware_kind", hardwareKind,
|
|
||||||
"error", HardwareErrorDetail(err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
||||||
|
|||||||
@@ -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) {
|
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||||
client := &transcriptClient{steps: []clientStep{
|
client := &transcriptClient{steps: []clientStep{
|
||||||
{
|
{
|
||||||
@@ -164,58 +141,6 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
|||||||
client.assertDone(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+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", imei: "861716070416510"}, 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.IMEI != "861716070416510" || !snapshot.SIMReady {
|
|
||||||
t.Fatalf("native QMI identity = %#v", snapshot)
|
|
||||||
}
|
|
||||||
client.assertDone(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSPNASCIIAndUCS2(t *testing.T) {
|
func TestParseSPNASCIIAndUCS2(t *testing.T) {
|
||||||
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
|
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
|
||||||
t.Fatalf("ASCII SPN = %q", got)
|
t.Fatalf("ASCII SPN = %q", got)
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
|
|||||||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
if IsPlaceholderIMSI(digits) {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
mcc = digits[:3]
|
mcc = digits[:3]
|
||||||
mnc = digits[3:]
|
mnc = digits[3:]
|
||||||
if mncLength != 2 && mncLength != 3 {
|
if mncLength != 2 && mncLength != 3 {
|
||||||
@@ -48,18 +45,6 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
|
|||||||
return mcc, mnc
|
return mcc, mnc
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPlaceholderIMSI recognizes an unprovisioned/test identity structurally,
|
|
||||||
// without tying the decision to a vendor-specific hard-coded ICCID. A valid
|
|
||||||
// subscriber identity cannot consist of an MCC followed only by zeroes; white
|
|
||||||
// cards commonly ship in exactly that state before a real profile is enabled.
|
|
||||||
func IsPlaceholderIMSI(imsi string) bool {
|
|
||||||
digits := strings.TrimSpace(imsi)
|
|
||||||
if len(digits) < 10 || strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.Trim(digits[3:], "0") == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegionBlockReason returns a human-readable reason when the SIM identified by
|
// RegionBlockReason returns a human-readable reason when the SIM identified by
|
||||||
// the IMSI belongs to a blocked region. It returns an empty string when the
|
// the IMSI belongs to a blocked region. It returns an empty string when the
|
||||||
// card is allowed or when the IMSI is unavailable: only a confirmed blocked
|
// card is allowed or when the IMSI is unavailable: only a confirmed blocked
|
||||||
|
|||||||
@@ -33,22 +33,6 @@ func TestCardMCCMNC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlaceholderIMSIIsNotTreatedAsARealCarrier(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
if !IsPlaceholderIMSI("460000000000000") {
|
|
||||||
t.Fatal("all-zero subscriber identity should be treated as an unprovisioned placeholder")
|
|
||||||
}
|
|
||||||
if IsPlaceholderIMSI("460001234567890") {
|
|
||||||
t.Fatal("real subscriber identity was classified as a placeholder")
|
|
||||||
}
|
|
||||||
if mcc, mnc := CardMCCMNC("460000000000000"); mcc != "" || mnc != "" {
|
|
||||||
t.Fatalf("placeholder MCC/MNC = %q/%q, want empty", mcc, mnc)
|
|
||||||
}
|
|
||||||
if reason := RegionBlockReason("460000000000000"); reason != "" {
|
|
||||||
t.Fatalf("placeholder identity was region-blocked: %s", reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegionBlockReason(t *testing.T) {
|
func TestRegionBlockReason(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
for _, imsi := range []string{"460001234567890", "461001234567890"} {
|
for _, imsi := range []string{"460001234567890", "461001234567890"} {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -74,17 +74,6 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) {
|
|
||||||
grouped := MCCsByCountry()
|
|
||||||
if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" {
|
|
||||||
t.Fatalf("GB MCCs = %#v", got)
|
|
||||||
}
|
|
||||||
grouped["GB"][0] = "999"
|
|
||||||
if country, ok := CountryForMCC("234"); !ok || country != "GB" {
|
|
||||||
t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
imsi string
|
imsi string
|
||||||
@@ -121,22 +110,3 @@ func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
|||||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCarrierForSIMRecognizesGiffgaffWithoutRelabelingGenericO2(t *testing.T) {
|
|
||||||
for _, identity := range []CarrierIdentity{
|
|
||||||
{IMSI: "234100000000001", GID1: "508FFFFF", MNCLength: 2},
|
|
||||||
{IMSI: "234100000000001", SPN: "GiffGaff", MNCLength: 2},
|
|
||||||
} {
|
|
||||||
plmn, name, country, ok := CarrierForSIM(identity)
|
|
||||||
if !ok || plmn != "23410" || name != "giffgaff" || country != "GB" {
|
|
||||||
t.Fatalf("giffgaff identity = (%q, %q, %q, %v)", plmn, name, country, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, name, _, ok := CarrierForSIM(CarrierIdentity{
|
|
||||||
IMSI: "234100000000001", MNCLength: 2,
|
|
||||||
})
|
|
||||||
if !ok || name == "giffgaff" {
|
|
||||||
t.Fatalf("generic O2 SIM was mislabeled as giffgaff: (%q, %v)", name, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,19 +39,6 @@ func (manager *Manager) readSnapshot(
|
|||||||
if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") {
|
if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") {
|
||||||
snapshot.Model = candidate.Product
|
snapshot.Model = candidate.Product
|
||||||
}
|
}
|
||||||
// Native MHI/QMI devices expose their immutable modem identity through DMS.
|
|
||||||
// Read it before any SIM-dependent AT probes: a missing/bad card can make
|
|
||||||
// those commands slow or fail, but must never prevent IMEI from appearing.
|
|
||||||
if strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
|
||||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
|
||||||
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
|
|
||||||
cancelQMI()
|
|
||||||
if qmiErr == nil {
|
|
||||||
snapshot.IMEI = qmiIMEI
|
|
||||||
} else {
|
|
||||||
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
optional := func(command string) (modem.Response, bool) {
|
optional := func(command string) (modem.Response, bool) {
|
||||||
response, commandErr := manager.command(ctx, client, command)
|
response, commandErr := manager.command(ctx, client, command)
|
||||||
@@ -69,28 +56,10 @@ func (manager *Manager) readSnapshot(
|
|||||||
if ccidErr != nil {
|
if ccidErr != nil {
|
||||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||||
}
|
}
|
||||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
|
|
||||||
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
|
|
||||||
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
|
|
||||||
// timeout, and every refresh holds the device lock while it does so,
|
|
||||||
// starving the AT terminal. Only fall back to QMI when the AT CPIN
|
|
||||||
// probe already proved a card is present.
|
|
||||||
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 {
|
if ccidErr != nil {
|
||||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||||
} else {
|
} 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)
|
previousICCID = strings.TrimSpace(previousICCID)
|
||||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||||
@@ -190,36 +159,15 @@ func (manager *Manager) readSnapshot(
|
|||||||
snapshot.RegistrationStatus = 1
|
snapshot.RegistrationStatus = 1
|
||||||
snapshot.RegistrationSource = "COPS"
|
snapshot.RegistrationSource = "COPS"
|
||||||
}
|
}
|
||||||
if snapshot.IMEI == "" {
|
if response, ok := optional("AT+CGSN"); ok {
|
||||||
// AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
|
snapshot.IMEI = parseIdentifier(
|
||||||
// returns the IMEI line but never a final OK, so it would block until the
|
response,
|
||||||
// caller's deadline (30s during a periodic refresh) and starve every other
|
[]string{"+CGSN:", "+GSN:"},
|
||||||
// device operation behind the lock. Give it an independent short timeout
|
14,
|
||||||
// and let the WWAN transport's drain discard the trailing stale bytes.
|
17,
|
||||||
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
|
)
|
||||||
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
|
|
||||||
cancelCGSN()
|
|
||||||
if cgsnErr == nil {
|
|
||||||
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
|
|
||||||
snapshot.IMEI = imei
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
|
||||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
|
||||||
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
|
|
||||||
cancelQMI()
|
|
||||||
if qmiErr == nil {
|
|
||||||
snapshot.IMEI = qmiIMEI
|
|
||||||
} else {
|
|
||||||
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if snapshot.IMEI == "" && previousSnapshot != nil {
|
|
||||||
// IMEI is hardware identity and does not change with the inserted card.
|
|
||||||
// Preserve a prior successful read across a transient QMI/AT failure.
|
|
||||||
snapshot.IMEI = previousSnapshot.IMEI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if response, ok := optional("AT+CFUN?"); ok {
|
if response, ok := optional("AT+CFUN?"); ok {
|
||||||
if mode, found := parseCFUN(response); found {
|
if mode, found := parseCFUN(response); found {
|
||||||
snapshot.OperatingMode = mode
|
snapshot.OperatingMode = mode
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"vocat/internal/modem"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lenientATClient answers every command with a bare CommandError and records
|
|
||||||
// the commands it saw. It lets snapshot tests exercise the full readSnapshot
|
|
||||||
// sequence without enumerating every step of the transcript.
|
|
||||||
type lenientATClient struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
commands []string
|
|
||||||
cgsnDelay time.Duration
|
|
||||||
cgsnIMEI string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *lenientATClient) Execute(ctx context.Context, command string) (modem.Response, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.commands = append(c.commands, command)
|
|
||||||
c.mu.Unlock()
|
|
||||||
if command == "ATI" {
|
|
||||||
return okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410"), nil
|
|
||||||
}
|
|
||||||
if command == "AT+CGSN" && c.cgsnDelay > 0 {
|
|
||||||
select {
|
|
||||||
case <-time.After(c.cgsnDelay):
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if command == "AT+CGSN" && c.cgsnIMEI != "" {
|
|
||||||
return okResponse("+CGSN: " + c.cgsnIMEI), nil
|
|
||||||
}
|
|
||||||
return modem.Response{}, &modem.CommandError{Command: command, Final: "ERROR"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *lenientATClient) WaitURC(context.Context, func(string) bool) (string, error) {
|
|
||||||
return "", errors.New("no URC")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *lenientATClient) Close() error { return nil }
|
|
||||||
|
|
||||||
func (c *lenientATClient) saw(command string) bool {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
for _, seen := range c.commands {
|
|
||||||
if seen == command {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// AT+CGSN on some MHI modems returns the IMEI line but never a final OK, so it
|
|
||||||
// would block until the caller's deadline and hold the device lock for the
|
|
||||||
// whole periodic refresh. The snapshot must bound CGSN with its own short
|
|
||||||
// timeout instead of inheriting the refresh deadline.
|
|
||||||
func TestManagerRefreshBoundsCGSNTimeout(t *testing.T) {
|
|
||||||
client := &lenientATClient{cgsnDelay: 5 * time.Second}
|
|
||||||
manager, id := newStartedTestManager(t, client)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
start := time.Now()
|
|
||||||
snapshot, err := manager.Refresh(ctx, id)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Refresh: %v", err)
|
|
||||||
}
|
|
||||||
// CGSN times out after CommandTimeout (1s in the test manager); the rest
|
|
||||||
// of the snapshot is immediate. An un-bounded CGSN would wait for the
|
|
||||||
// 4s outer deadline (or worse, a real 30s refresh deadline).
|
|
||||||
if elapsed > 3*time.Second {
|
|
||||||
t.Fatalf("Refresh took %s; CGSN was not bounded by CommandTimeout", elapsed)
|
|
||||||
}
|
|
||||||
if !client.saw("AT+CGSN") {
|
|
||||||
t.Fatalf("CGSN was never sent; commands = %v", client.commands)
|
|
||||||
}
|
|
||||||
if snapshot.IMEI != "" {
|
|
||||||
t.Fatalf("IMEI = %q, want empty after CGSN timeout", snapshot.IMEI)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A missing SIM must not fall back to the QMI UIM ICCID read: without a READY
|
|
||||||
// card that call blocks until its long timeout and starves the AT terminal
|
|
||||||
// behind the device lock.
|
|
||||||
func TestManagerRefreshSkipsQMIICCIDWithoutReadySIM(t *testing.T) {
|
|
||||||
// CGSN succeeds so the snapshot does not fall back to the QMI DMS IMEI
|
|
||||||
// read either; the test focuses on the UIM ICCID fallback being skipped
|
|
||||||
// without a READY card.
|
|
||||||
client := &lenientATClient{cgsnIMEI: "866241014372802"}
|
|
||||||
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()) })
|
|
||||||
|
|
||||||
qmiCalls := 0
|
|
||||||
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
|
||||||
qmiCalls++
|
|
||||||
return nil, errors.New("QMI should not be opened without a SIM")
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
// Exactly one QMI open is expected: the immutable DMS IMEI read runs
|
|
||||||
// unconditionally for native QMI candidates (IMEI is hardware identity,
|
|
||||||
// independent of the card). The UIM ICCID fallback, which would block
|
|
||||||
// without a READY SIM, must be skipped.
|
|
||||||
if qmiCalls != 1 {
|
|
||||||
t.Fatalf("qmiRadioOpener called %d times, want 1 (DMS IMEI only, UIM ICCID must be skipped without a READY SIM)", qmiCalls)
|
|
||||||
}
|
|
||||||
for _, warning := range snapshot.Warnings {
|
|
||||||
if strings.Contains(warning, "QMI UIM") {
|
|
||||||
t.Fatalf("unexpected QMI ICCID warning: %q", warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error {
|
|
||||||
control, native, err := manager.nativeQMIControl(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !native {
|
|
||||||
return errors.New("native QMI control is unavailable")
|
|
||||||
}
|
|
||||||
session, err := manager.qmiRadioOpener(ctx, control)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open native QMI control: %w", err)
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
qmiSession, ok := session.(nativeQMIVoWiFiSession)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("native QMI session lacks UIM/NAS support")
|
|
||||||
}
|
|
||||||
return fn(qmiSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadNativeQMIIdentity supplies the live subscription identity without using
|
|
||||||
// an AT port. The primitive return values intentionally keep device independent
|
|
||||||
// from the VoWiFi package while satisfying its narrow controller interface.
|
|
||||||
func (manager *Manager) ReadNativeQMIIdentity(ctx context.Context, id string) (iccid, imsi, imei, mcc, mnc string, err error) {
|
|
||||||
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
|
|
||||||
if iccid, err = session.GetICCID(ctx); err != nil {
|
|
||||||
return fmt.Errorf("read QMI ICCID: %w", err)
|
|
||||||
}
|
|
||||||
if imsi, err = session.GetIMSI(ctx); err != nil {
|
|
||||||
return fmt.Errorf("read QMI IMSI: %w", err)
|
|
||||||
}
|
|
||||||
if imei, err = session.GetIMEI(ctx); err != nil {
|
|
||||||
return fmt.Errorf("read QMI IMEI: %w", err)
|
|
||||||
}
|
|
||||||
if mcc, mnc, err = session.GetNativeMCCMNC(ctx); err != nil {
|
|
||||||
return fmt.Errorf("read QMI home PLMN: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, preference string) (aid []byte, application string, err error) {
|
|
||||||
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
|
|
||||||
aid, err = session.GetISIMAID(ctx)
|
|
||||||
application = "ISIM"
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if aid, err = session.GetUSIMAID(ctx); err == nil {
|
|
||||||
application = "USIM"
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
aid, err = session.GetISIMAID(ctx)
|
|
||||||
application = "ISIM"
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) {
|
|
||||||
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
|
|
||||||
channel, openErr := session.OpenLogicalChannel(ctx, 1, aid)
|
|
||||||
if openErr != nil {
|
|
||||||
return fmt.Errorf("open QMI UIM logical channel: %w", openErr)
|
|
||||||
}
|
|
||||||
command := append([]byte(nil), apdu...)
|
|
||||||
response, err = session.SendAPDU(ctx, 1, channel, command)
|
|
||||||
// ISO/IEC 7816-4 procedure bytes are transport-level continuation,
|
|
||||||
// not an AKA rejection. QMI exposes the raw status words, so follow
|
|
||||||
// 61xx/9Fxx with GET RESPONSE and retry 6Cxx with the advised Le while
|
|
||||||
// the same logical channel is still open.
|
|
||||||
for step := 0; err == nil && step < 4 && len(response) >= 2; step++ {
|
|
||||||
sw1, sw2 := response[len(response)-2], response[len(response)-1]
|
|
||||||
switch sw1 {
|
|
||||||
case 0x61, 0x9f:
|
|
||||||
response, err = session.SendAPDU(ctx, 1, channel, []byte{0x00, 0xc0, 0x00, 0x00, sw2})
|
|
||||||
case 0x6c:
|
|
||||||
if len(command) < 5 {
|
|
||||||
step = 4
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
command[len(command)-1] = sw2
|
|
||||||
response, err = session.SendAPDU(ctx, 1, channel, command)
|
|
||||||
default:
|
|
||||||
step = 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeErr := session.CloseLogicalChannel(ctx, 1, channel)
|
|
||||||
return errors.Join(err, closeErr)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) {
|
|
||||||
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
|
|
||||||
qmiMode, modeErr := session.GetOperatingMode(ctx)
|
|
||||||
if modeErr != nil {
|
|
||||||
return modeErr
|
|
||||||
}
|
|
||||||
mode = qmiModeAsCFUN(qmiMode)
|
|
||||||
serving, servingErr := session.GetServingSystem(ctx)
|
|
||||||
if servingErr == nil && serving != nil {
|
|
||||||
psAttached = serving.PSAttached
|
|
||||||
}
|
|
||||||
// An RF-off modem commonly rejects NAS serving-system queries; DMS mode
|
|
||||||
// remains sufficient evidence and data cannot be attached while RF is off.
|
|
||||||
if servingErr != nil && !isQMIRadioOffMode(qmiMode) {
|
|
||||||
return servingErr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) StopNativeQMICellularData(ctx context.Context, id string) error {
|
|
||||||
return manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
|
|
||||||
serving, err := session.GetServingSystem(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if serving == nil || !serving.PSAttached {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := session.AttachDetach(ctx, false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
deadline := time.NewTicker(250 * time.Millisecond)
|
|
||||||
defer deadline.Stop()
|
|
||||||
for attempt := 0; attempt < 12; attempt++ {
|
|
||||||
current, readErr := session.GetServingSystem(ctx)
|
|
||||||
if readErr == nil && (current == nil || !current.PSAttached) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-deadline.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors.New("native QMI packet service remained attached")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) SetNativeQMIRadioOff(ctx context.Context, id string, off bool) error {
|
|
||||||
_, err := manager.SetFlight(ctx, id, off)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) powerCycleNativeQMISIM(ctx context.Context, id string) (bool, error) {
|
|
||||||
control, native, err := manager.nativeQMIControl(id)
|
|
||||||
if err != nil || !native {
|
|
||||||
return native, err
|
|
||||||
}
|
|
||||||
session, err := manager.qmiRadioOpener(ctx, control)
|
|
||||||
if err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
uim, ok := session.(nativeQMIVoWiFiSession)
|
|
||||||
if !ok {
|
|
||||||
return true, errors.New("native QMI session lacks SIM power control")
|
|
||||||
}
|
|
||||||
if resetter, ok := session.(nativeQMIUIMResetSession); ok {
|
|
||||||
_ = resetter.ResetUIM(ctx)
|
|
||||||
}
|
|
||||||
if err := uim.PowerOffSIM(ctx, 1); err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return true, ctx.Err()
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
}
|
|
||||||
if err := uim.PowerOnSIM(ctx, 1); err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return true, ctx.Err()
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package i18n
|
|
||||||
|
|
||||||
// Keep feature-specific diagnostic strings together so additions to the proxy
|
|
||||||
// probe do not cause conflicts in the shared dictionary.
|
|
||||||
func init() {
|
|
||||||
zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall."
|
|
||||||
zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed."
|
|
||||||
zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed."
|
|
||||||
zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed."
|
|
||||||
}
|
|
||||||
+10
-78
@@ -11,10 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const quectelVendorID = "2c7c"
|
||||||
djiVendorID = "2ca3"
|
|
||||||
dji4GProductID = "4006"
|
|
||||||
)
|
|
||||||
type SysFSDiscoverer struct {
|
type SysFSDiscoverer struct {
|
||||||
SysRoot string
|
SysRoot string
|
||||||
DevRoot string
|
DevRoot string
|
||||||
@@ -42,19 +40,10 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
entries = nil
|
entries = nil
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("discover USB QMI modems: %w", err)
|
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidate modems are identified by kernel driver binding instead of a
|
|
||||||
// vendor-ID whitelist. qmi_wwan only binds Qualcomm QMI control interfaces,
|
|
||||||
// so any USB device with a bound interface exposes a live QMI channel. This
|
|
||||||
// keeps discovery vendor-neutral (SIMCom, Sierra, Telit and other
|
|
||||||
// Qualcomm-based modems are found automatically) while MBIM-only devices
|
|
||||||
// stay out, because cdc_mbim binds their control interface instead and the
|
|
||||||
// project has no MBIM backend.
|
|
||||||
qmiBound := d.qmiWWANBoundDevices()
|
|
||||||
|
|
||||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||||
devices := make(map[string]*discoveredUSBDevice)
|
devices := make(map[string]*discoveredUSBDevice)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -81,17 +70,17 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
resolvedDevice = devicePath
|
resolvedDevice = devicePath
|
||||||
}
|
}
|
||||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
if vendorID != quectelVendorID {
|
||||||
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
state := devices[deviceName]
|
state := devices[deviceName]
|
||||||
if state == nil {
|
if state == nil {
|
||||||
|
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||||
state = &discoveredUSBDevice{
|
state = &discoveredUSBDevice{
|
||||||
candidate: Candidate{
|
candidate: Candidate{
|
||||||
ID: candidateID(vendorID, productID, serialNumber, deviceName),
|
ID: candidateID(productID, serialNumber, deviceName),
|
||||||
VendorID: vendorID,
|
VendorID: vendorID,
|
||||||
ProductID: productID,
|
ProductID: productID,
|
||||||
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
||||||
@@ -141,14 +130,6 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
})
|
})
|
||||||
assignQuectelPortRoles(state.candidate.Ports)
|
assignQuectelPortRoles(state.candidate.Ports)
|
||||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||||
if !state.candidate.HasATPort() {
|
|
||||||
// A bound QMI interface proves the modem is alive, but the snapshot,
|
|
||||||
// SMS, USSD and eSIM (AT+CSIM) paths all require an AT port. A missing
|
|
||||||
// ttyUSB/ttyACM node almost always means the option/qcserial driver
|
|
||||||
// does not claim the serial interfaces (often a missing PID in its
|
|
||||||
// device-ID table), not that the module lacks an AT interface.
|
|
||||||
state.candidate.DiscoveryIssue = "at_port_missing"
|
|
||||||
}
|
|
||||||
result = append(result, state.candidate)
|
result = append(result, state.candidate)
|
||||||
}
|
}
|
||||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||||
@@ -160,14 +141,6 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
|
||||||
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
|
||||||
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
|
||||||
func IsDJI4GUSB(vendorID, productID string) bool {
|
|
||||||
return strings.EqualFold(strings.TrimSpace(vendorID), djiVendorID) &&
|
|
||||||
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
|
||||||
}
|
|
||||||
|
|
||||||
type discoveredWWANDevice struct {
|
type discoveredWWANDevice struct {
|
||||||
index string
|
index string
|
||||||
ports []Port
|
ports []Port
|
||||||
@@ -262,7 +235,7 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
|||||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||||
}
|
}
|
||||||
if len(group.ports) > 0 {
|
if len(group.ports) > 0 {
|
||||||
candidate.ATPort = selectWWANATPort(group.ports)
|
candidate.ATPort = group.ports[0]
|
||||||
}
|
}
|
||||||
if len(group.qmiNames) > 0 {
|
if len(group.qmiNames) > 0 {
|
||||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||||
@@ -273,20 +246,6 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectWWANATPort prefers the secondary AT port (…at1) over the primary
|
|
||||||
// (…at0) when both exist, falling back to the first AT port otherwise. Some
|
|
||||||
// Qualcomm MHI modems (notably the UFI dongle behind the OpenStick 410) answer
|
|
||||||
// on at1 immediately while at0 delays every response by 10-20 seconds, so the
|
|
||||||
// secondary port is the usable AT channel.
|
|
||||||
func selectWWANATPort(ports []Port) Port {
|
|
||||||
for _, port := range ports {
|
|
||||||
if port.InterfaceNumber == 1 {
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ports[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||||
if !strings.HasPrefix(name, "wwan") {
|
if !strings.HasPrefix(name, "wwan") {
|
||||||
return "", "", 0, false
|
return "", "", 0, false
|
||||||
@@ -426,34 +385,7 @@ func readSerialAliases(root string) map[string]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// qmiWWANBoundDevices returns the set of USB device paths (for example "1-6"
|
func candidateID(productID, serialNumber, usbName string) string {
|
||||||
// or the hub-attached "1-4.3.2") that currently have at least one interface
|
|
||||||
// bound to the kernel's qmi_wwan driver. Interface entries in the driver
|
|
||||||
// directory are named "<device-path>:<interface>.<altsetting>", so the part
|
|
||||||
// before the first colon is the owning USB device. The qmi_wwan driver only
|
|
||||||
// binds Qualcomm QMI control interfaces, so membership doubles as a vendor-
|
|
||||||
// neutral "this is a live QMI modem" signal.
|
|
||||||
func (d *SysFSDiscoverer) qmiWWANBoundDevices() map[string]struct{} {
|
|
||||||
driverRoot := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
|
||||||
entries, err := os.ReadDir(driverRoot)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
devices := make(map[string]struct{})
|
|
||||||
for _, entry := range entries {
|
|
||||||
// The driver directory also holds control files (bind, unbind, uevent,
|
|
||||||
// module, new_id, ...); only names containing a colon are interfaces.
|
|
||||||
deviceName, _, ok := strings.Cut(entry.Name(), ":")
|
|
||||||
if !ok || deviceName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
devices[deviceName] = struct{}{}
|
|
||||||
}
|
|
||||||
return devices
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
|
||||||
prefix := "usb-" + sanitizeID(vendorID)
|
|
||||||
serialNumber = strings.TrimSpace(serialNumber)
|
serialNumber = strings.TrimSpace(serialNumber)
|
||||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||||
@@ -462,9 +394,9 @@ func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
|||||||
// to the same hub into one entry. Include the physical USB topology in the
|
// to the same hub into one entry. Include the physical USB topology in the
|
||||||
// discovery key; configured devices remain stable through ATMapper's
|
// discovery key; configured devices remain stable through ATMapper's
|
||||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||||
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
||||||
}
|
}
|
||||||
return prefix + "-" + sanitizeID(productID+"-"+usbName)
|
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeID(value string) string {
|
func sanitizeID(value string) string {
|
||||||
|
|||||||
@@ -2,29 +2,6 @@ package modem
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestIsDJI4GUSBIdentity(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
vendorID string
|
|
||||||
productID string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
|
||||||
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
|
||||||
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
|
||||||
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
|
|
||||||
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
|
|
||||||
t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
||||||
ports := []Port{
|
ports := []Port{
|
||||||
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
|
||||||
|
|
||||||
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
||||||
candidates, err := discoverer.Discover(context.Background())
|
candidates, err := discoverer.Discover(context.Background())
|
||||||
@@ -59,7 +57,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
}
|
}
|
||||||
candidate := candidates[0]
|
candidate := candidates[0]
|
||||||
if candidate.ID != "usb-2c7c-0125-1-6" {
|
if candidate.ID != "quectel-0125-1-6" {
|
||||||
t.Fatalf("ID = %q", candidate.ID)
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Name != "ttyUSB2" {
|
if candidate.ATPort.Name != "ttyUSB2" {
|
||||||
@@ -103,7 +101,6 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
|||||||
)
|
)
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
||||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -149,7 +146,6 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||||
mustBindQMIWWAN(t, sysRoot, modem.usbName+":1.4")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -198,7 +194,6 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||||
mustBindQMIWWAN(t, sysRoot, item.usbName+":1.4")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -221,11 +216,9 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||||
// A plain USB serial adapter (FTDI) exposes ttyUSB but no QMI interface and
|
|
||||||
// is never bound to qmi_wwan, so it must not be treated as a modem.
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
||||||
@@ -242,117 +235,6 @@ func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryFindsNonQuectelVendorBoundToQMIWWAN(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
sysRoot := filepath.Join(root, "sys")
|
|
||||||
devRoot := filepath.Join(root, "dev")
|
|
||||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
|
||||||
// A Sierra EM7430 flashed to its QMI (rmnet0) composition: non-Quectel
|
|
||||||
// vendor, but its control interface is bound to qmi_wwan.
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idVendor"), "1199\n")
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idProduct"), "9077\n")
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "manufacturer"), "Sierra Wireless, Incorporated\n")
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "product"), "EM7430\n")
|
|
||||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
|
||||||
interfaceName := "1-3:1." + strconv.Itoa(number)
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
|
||||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
|
||||||
}
|
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-3:1.4", "usbmisc", "cdc-wdm0"))
|
|
||||||
mustBindQMIWWAN(t, sysRoot, "1-3:1.4")
|
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Discover: %v", err)
|
|
||||||
}
|
|
||||||
if len(candidates) != 1 {
|
|
||||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
|
||||||
}
|
|
||||||
candidate := candidates[0]
|
|
||||||
if candidate.VendorID != "1199" || candidate.Product != "EM7430" {
|
|
||||||
t.Fatalf("candidate = %#v", candidate)
|
|
||||||
}
|
|
||||||
if candidate.ID != "usb-1199-9077-1-3" {
|
|
||||||
t.Fatalf("ID = %q", candidate.ID)
|
|
||||||
}
|
|
||||||
if candidate.ATPort.Role != PortRoleAT {
|
|
||||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
|
||||||
}
|
|
||||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
|
||||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
|
||||||
}
|
|
||||||
if candidate.DiscoveryIssue != "" {
|
|
||||||
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSysFSDiscoveryMarksQMIModemWithoutATPort(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
sysRoot := filepath.Join(root, "sys")
|
|
||||||
devRoot := filepath.Join(root, "dev")
|
|
||||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
|
||||||
// QMI control interface is bound, but no ttyUSB/ttyACM node exists (for
|
|
||||||
// example the option/qcserial driver does not claim the serial interfaces).
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idVendor"), "2c7c\n")
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idProduct"), "0125\n")
|
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-7:1.4", "usbmisc", "cdc-wdm0"))
|
|
||||||
mustBindQMIWWAN(t, sysRoot, "1-7:1.4")
|
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Discover: %v", err)
|
|
||||||
}
|
|
||||||
if len(candidates) != 1 {
|
|
||||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
|
||||||
}
|
|
||||||
candidate := candidates[0]
|
|
||||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
|
||||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
|
||||||
}
|
|
||||||
if candidate.HasATPort() {
|
|
||||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
|
||||||
}
|
|
||||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
|
||||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSysFSDiscoveryFindsHubAttachedQMIWWANDevice(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
sysRoot := filepath.Join(root, "sys")
|
|
||||||
devRoot := filepath.Join(root, "dev")
|
|
||||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
|
||||||
// Device behind a hub: the USB path "1-4.3.2" contains extra segments, and
|
|
||||||
// the qmi_wwan binding uses the same composite path before the colon.
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idVendor"), "2c7c\n")
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idProduct"), "0125\n")
|
|
||||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
|
||||||
interfaceName := "1-4.3.2:1." + strconv.Itoa(number)
|
|
||||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
|
||||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
|
||||||
}
|
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-4.3.2:1.4", "usbmisc", "cdc-wdm0"))
|
|
||||||
mustBindQMIWWAN(t, sysRoot, "1-4.3.2:1.4")
|
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Discover: %v", err)
|
|
||||||
}
|
|
||||||
if len(candidates) != 1 {
|
|
||||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
|
||||||
}
|
|
||||||
candidate := candidates[0]
|
|
||||||
if candidate.ATPort.Name != "ttyUSB2" {
|
|
||||||
t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort)
|
|
||||||
}
|
|
||||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
|
||||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
|
||||||
}
|
|
||||||
if !strings.Contains(candidate.ID, "1-4-3-2") {
|
|
||||||
t.Fatalf("ID = %q, want hub topology in discovery key", candidate.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
sysRoot := filepath.Join(root, "sys")
|
sysRoot := filepath.Join(root, "sys")
|
||||||
@@ -374,7 +256,7 @@ func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
|||||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||||
t.Fatalf("identity = %#v", candidate)
|
t.Fatalf("identity = %#v", candidate)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at1") || candidate.ATPort.Role != PortRoleAT {
|
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
||||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||||
}
|
}
|
||||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||||
@@ -408,23 +290,6 @@ func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectWWANATPortPrefersSecondaryATPort(t *testing.T) {
|
|
||||||
ports := []Port{
|
|
||||||
{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT},
|
|
||||||
{Name: "wwan0at1", InterfaceNumber: 1, Role: PortRoleAT},
|
|
||||||
}
|
|
||||||
if got := selectWWANATPort(ports); got.Name != "wwan0at1" {
|
|
||||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at1", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectWWANATPortFallsBackToPrimaryWhenOnlyAT0(t *testing.T) {
|
|
||||||
ports := []Port{{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT}}
|
|
||||||
if got := selectWWANATPort(ports); got.Name != "wwan0at0" {
|
|
||||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseWWANPortName(t *testing.T) {
|
func TestParseWWANPortName(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name, index, kind string
|
name, index, kind string
|
||||||
@@ -458,18 +323,3 @@ func mustMkdir(t *testing.T, path string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustBindQMIWWAN mimics the kernel's driver-binding directory entry: it adds
|
|
||||||
// interfaceName (e.g. "1-6:1.4") under /sys/bus/usb/drivers/qmi_wwan exactly
|
|
||||||
// like the real qmi_wwan driver directory does for a bound QMI interface.
|
|
||||||
func mustBindQMIWWAN(t *testing.T, sysRoot, interfaceName string) {
|
|
||||||
t.Helper()
|
|
||||||
driverDir := filepath.Join(sysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
|
||||||
if err := os.MkdirAll(driverDir, 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
target := filepath.Join(sysRoot, "bus", "usb", "devices", interfaceName)
|
|
||||||
if err := os.Symlink(target, filepath.Join(driverDir, interfaceName)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
)
|
)
|
||||||
@@ -22,22 +21,6 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error)
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, errors.New("modem: candidate has no AT port")
|
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
|
baudRate := opener.BaudRate
|
||||||
if baudRate <= 0 {
|
if baudRate <= 0 {
|
||||||
baudRate = 115200
|
baudRate = 115200
|
||||||
@@ -62,8 +45,3 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error)
|
|||||||
}
|
}
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNativeWWANATPath(path string) bool {
|
|
||||||
_, kind, _, ok := parseWWANPortName(filepath.Base(filepath.Clean(path)))
|
|
||||||
return ok && kind == "at"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,19 +147,14 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
// Drain the transport before writing the command. Serial transports wait
|
|
||||||
// for any pending output here (a no-op after a synchronous command), while
|
|
||||||
// WWAN transports discard bytes left over from a previous command that
|
|
||||||
// timed out; without this, a late reply (e.g. a slow CGSN response) would
|
|
||||||
// be mis-parsed as this command's output.
|
|
||||||
if err := drainTransport(ctx, session.transport); err != nil {
|
|
||||||
session.poisonLocked()
|
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
|
||||||
}
|
|
||||||
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
}
|
}
|
||||||
|
if err := session.transport.Drain(); err != nil {
|
||||||
|
session.poisonLocked()
|
||||||
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
|
}
|
||||||
return session.readFinalLocked(ctx, started, command, "", response)
|
return session.readFinalLocked(ctx, started, command, "", response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +178,7 @@ func (session *Session) executePromptLocked(
|
|||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := drainTransport(ctx, session.transport); err != nil {
|
if err := session.transport.Drain(); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
@@ -209,7 +203,7 @@ func (session *Session) executePromptLocked(
|
|||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := drainTransport(ctx, session.transport); err != nil {
|
if err := session.transport.Drain(); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
||||||
@@ -217,21 +211,6 @@ func (session *Session) executePromptLocked(
|
|||||||
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// drainTransport retries tcdrain/TCSBRK when the kernel interrupts it with a
|
|
||||||
// signal. go.bug.st/serial already retries EINTR for Read, but its Linux
|
|
||||||
// Drain implementation currently returns the transient error directly.
|
|
||||||
func drainTransport(ctx context.Context, transport Transport) error {
|
|
||||||
for {
|
|
||||||
err := transport.Drain()
|
|
||||||
if !errors.Is(err, syscall.EINTR) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *Session) readFinalLocked(
|
func (session *Session) readFinalLocked(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
started time.Time,
|
started time.Time,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -28,8 +27,6 @@ type transcriptTransport struct {
|
|||||||
unexpected error
|
unexpected error
|
||||||
writePartial bool
|
writePartial bool
|
||||||
writeEvents chan string
|
writeEvents chan string
|
||||||
drainErrors []error
|
|
||||||
drainCount int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
||||||
@@ -117,17 +114,7 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) {
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) Drain() error {
|
func (transport *transcriptTransport) Drain() error { return nil }
|
||||||
transport.mu.Lock()
|
|
||||||
defer transport.mu.Unlock()
|
|
||||||
transport.drainCount++
|
|
||||||
if len(transport.drainErrors) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err := transport.drainErrors[0]
|
|
||||||
transport.drainErrors = transport.drainErrors[1:]
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *transcriptTransport) ResetInputBuffer() error {
|
func (transport *transcriptTransport) ResetInputBuffer() error {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
@@ -151,31 +138,6 @@ func (transport *transcriptTransport) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionRetriesInterruptedDrain(t *testing.T) {
|
|
||||||
transport := &transcriptTransport{
|
|
||||||
steps: []transportStep{{
|
|
||||||
write: "AT+CSQ\r",
|
|
||||||
chunks: []string{"\r\nAT+CSQ\r\n+CSQ: 24,99\r\nOK\r\n"},
|
|
||||||
}},
|
|
||||||
drainErrors: []error{syscall.EINTR},
|
|
||||||
}
|
|
||||||
session, err := NewSession(transport, SessionOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSession() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := session.Execute(context.Background(), "AT+CSQ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Execute() error = %v", err)
|
|
||||||
}
|
|
||||||
if response.Final != "OK" {
|
|
||||||
t.Fatalf("response final = %q", response.Final)
|
|
||||||
}
|
|
||||||
if transport.drainCount != 2 {
|
|
||||||
t.Fatalf("Drain() calls = %d, want 2", transport.drainCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
||||||
transport := &transcriptTransport{steps: []transportStep{{
|
transport := &transcriptTransport{steps: []transportStep{{
|
||||||
write: "AT+CSQ\r",
|
write: "AT+CSQ\r",
|
||||||
@@ -453,62 +415,6 @@ func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// drainOrderTransport forwards to an inner Transport while recording
|
|
||||||
// transport-level events, so a test can assert the exact order of Drain and
|
|
||||||
// Write calls.
|
|
||||||
type drainOrderTransport struct {
|
|
||||||
inner Transport
|
|
||||||
events chan string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *drainOrderTransport) Write(payload []byte) (int, error) {
|
|
||||||
transport.events <- "write:" + string(payload)
|
|
||||||
return transport.inner.Write(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *drainOrderTransport) Read(buffer []byte) (int, error) {
|
|
||||||
return transport.inner.Read(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *drainOrderTransport) Drain() error {
|
|
||||||
transport.events <- "drain"
|
|
||||||
return transport.inner.Drain()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *drainOrderTransport) ResetInputBuffer() error {
|
|
||||||
return transport.inner.ResetInputBuffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *drainOrderTransport) SetReadTimeout(timeout time.Duration) error {
|
|
||||||
return transport.inner.SetReadTimeout(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transport *drainOrderTransport) Close() error {
|
|
||||||
return transport.inner.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// WWAN transports discard stale bytes left over from a timed-out command
|
|
||||||
// inside Drain, so the session must call it before writing the next command;
|
|
||||||
// otherwise a late reply (e.g. a slow CGSN response) would be mis-parsed as
|
|
||||||
// the new command's output.
|
|
||||||
func TestSessionDrainsBeforeWritingCommand(t *testing.T) {
|
|
||||||
inner := &transcriptTransport{steps: []transportStep{{
|
|
||||||
write: "AT+CSQ\r",
|
|
||||||
chunks: []string{"\r\n+CSQ: 24,99\r\nOK\r\n"},
|
|
||||||
}}}
|
|
||||||
events := make(chan string, 8)
|
|
||||||
session := newTestSession(t, &drainOrderTransport{inner: inner, events: events})
|
|
||||||
if _, err := session.Execute(context.Background(), "AT+CSQ"); err != nil {
|
|
||||||
t.Fatalf("Execute: %v", err)
|
|
||||||
}
|
|
||||||
if first := <-events; first != "drain" {
|
|
||||||
t.Fatalf("first transport event = %q, want drain before the command write", first)
|
|
||||||
}
|
|
||||||
if second := <-events; second != "write:AT+CSQ\r" {
|
|
||||||
t.Fatalf("second transport event = %q, want the command write", second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestSession(t *testing.T, transport Transport) *Session {
|
func newTestSession(t *testing.T, transport Transport) *Session {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
session, err := NewSession(transport, SessionOptions{
|
session, err := NewSession(transport, SessionOptions{
|
||||||
|
|||||||
@@ -1,180 +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. A previous command that timed out
|
|
||||||
// can leave late bytes in the input buffer (e.g. a slow CGSN reply that
|
|
||||||
// arrives after the command deadline); discard them here so the next
|
|
||||||
// command starts from a clean stream instead of mis-parsing stale output.
|
|
||||||
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) 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)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package modem
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestNativeWWANATTransportDrainDiscardsPendingBytes verifies Drain discards
|
|
||||||
// every byte already buffered on the transport. A command that timed out (e.g.
|
|
||||||
// AT+CGSN on an MHI modem that never answers OK) can leave its late reply in
|
|
||||||
// the input buffer; the next command's Drain must clear it, however much data
|
|
||||||
// is pending, before the session writes the new command.
|
|
||||||
func TestNativeWWANATTransportDrainDiscardsPendingBytes(t *testing.T) {
|
|
||||||
readFD, writeFD := socketpair(t)
|
|
||||||
defer unix.Close(writeFD)
|
|
||||||
|
|
||||||
// More than one 4096-byte Drain read: a slow CGSN reply (echo + IMEI +
|
|
||||||
// trailing CRLF) can exceed a single buffer.
|
|
||||||
payload := make([]byte, 12000)
|
|
||||||
for index := range payload {
|
|
||||||
payload[index] = byte('A' + index%26)
|
|
||||||
}
|
|
||||||
payload = append(payload, []byte("\r\n+CGSN: 357091089453326\r\n")...)
|
|
||||||
if _, err := unix.Write(writeFD, payload); err != nil {
|
|
||||||
t.Fatalf("seed stale bytes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
|
||||||
if err := transport.Drain(); err != nil {
|
|
||||||
t.Fatalf("Drain: %v", err)
|
|
||||||
}
|
|
||||||
assertNoPendingBytes(t, readFD, "after Drain")
|
|
||||||
|
|
||||||
// Draining a clean transport is a fast no-op that must not block or error.
|
|
||||||
if err := transport.Drain(); err != nil {
|
|
||||||
t.Fatalf("second Drain: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNativeWWANATTransportDrainRejectsClosedTransport covers the guard that
|
|
||||||
// keeps a poisoned session from draining a wedged, already-closed fd.
|
|
||||||
func TestNativeWWANATTransportDrainRejectsClosedTransport(t *testing.T) {
|
|
||||||
readFD, writeFD := socketpair(t)
|
|
||||||
defer unix.Close(writeFD)
|
|
||||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
|
||||||
if err := transport.Close(); err != nil {
|
|
||||||
t.Fatalf("Close: %v", err)
|
|
||||||
}
|
|
||||||
if err := transport.Drain(); !errors.Is(err, io.ErrClosedPipe) {
|
|
||||||
t.Fatalf("Drain after Close = %v, want ErrClosedPipe", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func socketpair(t *testing.T) (int, int) {
|
|
||||||
t.Helper()
|
|
||||||
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return fds[0], fds[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertNoPendingBytes(t *testing.T, fd int, context string) {
|
|
||||||
t.Helper()
|
|
||||||
fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
|
||||||
ready, err := unix.Poll(fds, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("poll %s: %v", context, err)
|
|
||||||
}
|
|
||||||
if ready != 0 {
|
|
||||||
t.Fatalf("%s: fd still readable", context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+1
-246
@@ -3,8 +3,6 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -19,44 +17,18 @@ type ProbeResult struct {
|
|||||||
Reachable bool `json:"reachable"`
|
Reachable bool `json:"reachable"`
|
||||||
HandshakeOK bool `json:"handshake_ok"`
|
HandshakeOK bool `json:"handshake_ok"`
|
||||||
UDPAssociateOK bool `json:"udp_associate_ok"`
|
UDPAssociateOK bool `json:"udp_associate_ok"`
|
||||||
UDPExchangeOK bool `json:"udp_exchange_ok"`
|
|
||||||
AuthMethod string `json:"auth_method,omitempty"`
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
RelayAddr string `json:"relay_addr,omitempty"`
|
RelayAddr string `json:"relay_addr,omitempty"`
|
||||||
DNSServer string `json:"dns_server,omitempty"`
|
|
||||||
DNSName string `json:"dns_name,omitempty"`
|
|
||||||
DNSRCode int `json:"dns_rcode,omitempty"`
|
|
||||||
RoundTripMS int64 `json:"round_trip_ms,omitempty"`
|
|
||||||
Diagnosis string `json:"diagnosis,omitempty"`
|
Diagnosis string `json:"diagnosis,omitempty"`
|
||||||
Hint string `json:"hint,omitempty"`
|
Hint string `json:"hint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
defaultProbeDNSServer = "1.1.1.1:53"
|
|
||||||
defaultProbeDNSName = "example.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ProbeSOCKS5(
|
func ProbeSOCKS5(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
address string,
|
address string,
|
||||||
username string,
|
username string,
|
||||||
password string,
|
password string,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
) (ProbeResult, error) {
|
|
||||||
return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real
|
|
||||||
// UDP DNS round trip through the returned relay. Keeping the target injectable
|
|
||||||
// makes the negative paths deterministic in tests without weakening the
|
|
||||||
// production probe.
|
|
||||||
func probeSOCKS5(
|
|
||||||
ctx context.Context,
|
|
||||||
address string,
|
|
||||||
username string,
|
|
||||||
password string,
|
|
||||||
timeout time.Duration,
|
|
||||||
dnsServer string,
|
|
||||||
dnsName string,
|
|
||||||
) (ProbeResult, error) {
|
) (ProbeResult, error) {
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||||
@@ -150,228 +122,11 @@ func probeSOCKS5(
|
|||||||
port := int(portBytes[0])<<8 | int(portBytes[1])
|
port := int(portBytes[0])<<8 | int(portBytes[1])
|
||||||
result.UDPAssociateOK = true
|
result.UDPAssociateOK = true
|
||||||
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||||
result.DNSServer = dnsServer
|
|
||||||
result.DNSName = dnsName
|
|
||||||
|
|
||||||
if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil {
|
|
||||||
if result.Diagnosis == "" {
|
|
||||||
result.Diagnosis = "udp_no_roundtrip"
|
|
||||||
}
|
|
||||||
if result.Hint == "" {
|
|
||||||
result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。")
|
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
result.Diagnosis = "ready"
|
result.Diagnosis = "ready"
|
||||||
result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。")
|
result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。")
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func probeUDPExchange(
|
|
||||||
ctx context.Context,
|
|
||||||
control net.Conn,
|
|
||||||
result *ProbeResult,
|
|
||||||
relayHost string,
|
|
||||||
relayPort int,
|
|
||||||
dnsServer string,
|
|
||||||
dnsName string,
|
|
||||||
timeout time.Duration,
|
|
||||||
) error {
|
|
||||||
if result == nil {
|
|
||||||
return errors.New("proxy: probe result is nil")
|
|
||||||
}
|
|
||||||
dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer))
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "invalid_dns_target"
|
|
||||||
return fmt.Errorf("proxy: resolve UDP probe target: %w", err)
|
|
||||||
}
|
|
||||||
relayHost = strings.TrimSpace(relayHost)
|
|
||||||
if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() {
|
|
||||||
remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String())
|
|
||||||
if splitErr != nil {
|
|
||||||
result.Diagnosis = "invalid_udp_relay"
|
|
||||||
return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr)
|
|
||||||
}
|
|
||||||
relayHost = remoteHost
|
|
||||||
}
|
|
||||||
relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort)))
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "invalid_udp_relay"
|
|
||||||
return fmt.Errorf("proxy: resolve UDP relay: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
localNetwork := "udp4"
|
|
||||||
if relayAddress.IP != nil && relayAddress.IP.To4() == nil {
|
|
||||||
localNetwork = "udp6"
|
|
||||||
}
|
|
||||||
udpConnection, err := net.ListenUDP(localNetwork, nil)
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "udp_socket_failed"
|
|
||||||
return fmt.Errorf("proxy: open UDP probe socket: %w", err)
|
|
||||||
}
|
|
||||||
defer udpConnection.Close()
|
|
||||||
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
|
||||||
deadline = contextDeadline
|
|
||||||
}
|
|
||||||
if err := udpConnection.SetDeadline(deadline); err != nil {
|
|
||||||
return fmt.Errorf("proxy: set UDP probe deadline: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query, queryID, err := buildDNSQuery(dnsName)
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "invalid_dns_name"
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
datagram, err := buildSOCKSUDPDatagram(dnsAddress, query)
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "invalid_dns_target"
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
startedAt := time.Now()
|
|
||||||
if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil {
|
|
||||||
result.Diagnosis = "udp_send_failed"
|
|
||||||
return fmt.Errorf("proxy: send UDP DNS probe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
responseBuffer := make([]byte, 64*1024)
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
result.Diagnosis = "udp_no_roundtrip"
|
|
||||||
return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err)
|
|
||||||
}
|
|
||||||
count, sender, err := udpConnection.ReadFromUDP(responseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "udp_no_roundtrip"
|
|
||||||
return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err)
|
|
||||||
}
|
|
||||||
if !sameUDPAddress(sender, relayAddress) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
payload, err := parseSOCKSUDPDatagram(responseBuffer[:count])
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "udp_invalid_response"
|
|
||||||
return fmt.Errorf("proxy: parse UDP relay response: %w", err)
|
|
||||||
}
|
|
||||||
rcode, err := validateDNSResponse(payload, queryID)
|
|
||||||
if err != nil {
|
|
||||||
result.Diagnosis = "dns_invalid_response"
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
result.UDPExchangeOK = true
|
|
||||||
result.DNSRCode = rcode
|
|
||||||
result.RoundTripMS = time.Since(startedAt).Milliseconds()
|
|
||||||
if result.RoundTripMS < 1 {
|
|
||||||
result.RoundTripMS = 1
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDNSQuery(name string) ([]byte, uint16, error) {
|
|
||||||
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
|
||||||
if name == "" || len(name) > 253 {
|
|
||||||
return nil, 0, errors.New("proxy: UDP probe DNS name is invalid")
|
|
||||||
}
|
|
||||||
var idBytes [2]byte
|
|
||||||
if _, err := rand.Read(idBytes[:]); err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err)
|
|
||||||
}
|
|
||||||
queryID := binary.BigEndian.Uint16(idBytes[:])
|
|
||||||
query := make([]byte, 12, 12+len(name)+6)
|
|
||||||
binary.BigEndian.PutUint16(query[0:2], queryID)
|
|
||||||
binary.BigEndian.PutUint16(query[2:4], 0x0100)
|
|
||||||
binary.BigEndian.PutUint16(query[4:6], 1)
|
|
||||||
for _, label := range strings.Split(name, ".") {
|
|
||||||
if label == "" || len(label) > 63 {
|
|
||||||
return nil, 0, errors.New("proxy: UDP probe DNS label is invalid")
|
|
||||||
}
|
|
||||||
query = append(query, byte(len(label)))
|
|
||||||
query = append(query, label...)
|
|
||||||
}
|
|
||||||
query = append(query, 0, 0, 1, 0, 1)
|
|
||||||
return query, queryID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) {
|
|
||||||
if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 {
|
|
||||||
return nil, errors.New("proxy: UDP target is invalid")
|
|
||||||
}
|
|
||||||
packet := []byte{0, 0, 0}
|
|
||||||
if ipv4 := target.IP.To4(); ipv4 != nil {
|
|
||||||
packet = append(packet, 1)
|
|
||||||
packet = append(packet, ipv4...)
|
|
||||||
} else if ipv6 := target.IP.To16(); ipv6 != nil {
|
|
||||||
packet = append(packet, 4)
|
|
||||||
packet = append(packet, ipv6...)
|
|
||||||
} else {
|
|
||||||
return nil, errors.New("proxy: UDP target address family is invalid")
|
|
||||||
}
|
|
||||||
packet = append(packet, byte(target.Port>>8), byte(target.Port))
|
|
||||||
packet = append(packet, payload...)
|
|
||||||
return packet, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) {
|
|
||||||
if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 {
|
|
||||||
return nil, errors.New("invalid SOCKS5 UDP header")
|
|
||||||
}
|
|
||||||
if packet[2] != 0 {
|
|
||||||
return nil, errors.New("fragmented SOCKS5 UDP response is unsupported")
|
|
||||||
}
|
|
||||||
offset := 4
|
|
||||||
switch packet[3] {
|
|
||||||
case 1:
|
|
||||||
offset += net.IPv4len
|
|
||||||
case 3:
|
|
||||||
if len(packet) <= offset {
|
|
||||||
return nil, errors.New("truncated SOCKS5 UDP domain")
|
|
||||||
}
|
|
||||||
offset += 1 + int(packet[offset])
|
|
||||||
case 4:
|
|
||||||
offset += net.IPv6len
|
|
||||||
default:
|
|
||||||
return nil, errors.New("unsupported SOCKS5 UDP address type")
|
|
||||||
}
|
|
||||||
if offset+2 > len(packet) {
|
|
||||||
return nil, errors.New("truncated SOCKS5 UDP endpoint")
|
|
||||||
}
|
|
||||||
offset += 2
|
|
||||||
if offset >= len(packet) {
|
|
||||||
return nil, errors.New("empty SOCKS5 UDP payload")
|
|
||||||
}
|
|
||||||
return packet[offset:], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateDNSResponse(payload []byte, queryID uint16) (int, error) {
|
|
||||||
if len(payload) < 12 {
|
|
||||||
return 0, errors.New("proxy: DNS response is truncated")
|
|
||||||
}
|
|
||||||
if binary.BigEndian.Uint16(payload[0:2]) != queryID {
|
|
||||||
return 0, errors.New("proxy: DNS response ID does not match")
|
|
||||||
}
|
|
||||||
flags := binary.BigEndian.Uint16(payload[2:4])
|
|
||||||
if flags&0x8000 == 0 {
|
|
||||||
return 0, errors.New("proxy: DNS response is not a response")
|
|
||||||
}
|
|
||||||
rcode := int(flags & 0x000f)
|
|
||||||
if rcode != 0 {
|
|
||||||
return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode)
|
|
||||||
}
|
|
||||||
return rcode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameUDPAddress(left, right *net.UDPAddr) bool {
|
|
||||||
if left == nil || right == nil || left.Port != right.Port {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if left.IP == nil || right.IP == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return left.IP.Equal(right.IP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
||||||
switch addressType {
|
switch addressType {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) {
|
|
||||||
address, stop := startProbeSOCKS5Server(t, false)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
result, err := probeSOCKS5(
|
|
||||||
context.Background(),
|
|
||||||
address,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
250*time.Millisecond,
|
|
||||||
"192.0.2.53:53",
|
|
||||||
"example.test",
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data")
|
|
||||||
}
|
|
||||||
if !result.UDPAssociateOK {
|
|
||||||
t.Fatal("UDP ASSOCIATE should have succeeded")
|
|
||||||
}
|
|
||||||
if result.UDPExchangeOK {
|
|
||||||
t.Fatal("UDP exchange should not be reported as successful")
|
|
||||||
}
|
|
||||||
if result.Diagnosis != "udp_no_roundtrip" {
|
|
||||||
t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) {
|
|
||||||
address, stop := startProbeSOCKS5Server(t, true)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
result, err := probeSOCKS5(
|
|
||||||
context.Background(),
|
|
||||||
address,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
time.Second,
|
|
||||||
"192.0.2.53:53",
|
|
||||||
"example.test",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Probe returned error: %v", err)
|
|
||||||
}
|
|
||||||
if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK {
|
|
||||||
t.Fatalf("Probe evidence incomplete: %+v", result)
|
|
||||||
}
|
|
||||||
if result.Diagnosis != "ready" {
|
|
||||||
t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis)
|
|
||||||
}
|
|
||||||
if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" {
|
|
||||||
t.Fatalf("Unexpected DNS evidence: %+v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) {
|
|
||||||
t.Helper()
|
|
||||||
udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListenUDP: %v", err)
|
|
||||||
}
|
|
||||||
tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
udpConnection.Close()
|
|
||||||
t.Fatalf("Listen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if echoDNS {
|
|
||||||
go func() {
|
|
||||||
buffer := make([]byte, 2048)
|
|
||||||
count, sender, readErr := udpConnection.ReadFromUDP(buffer)
|
|
||||||
if readErr != nil || count < 22 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// The test target is IPv4, so the SOCKS5 UDP header is ten bytes.
|
|
||||||
buffer[12] = 0x81
|
|
||||||
buffer[13] = 0x80
|
|
||||||
_, _ = udpConnection.WriteToUDP(buffer[:count], sender)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
connection, acceptErr := tcpListener.Accept()
|
|
||||||
if acceptErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer connection.Close()
|
|
||||||
greeting := make([]byte, 3)
|
|
||||||
if _, readErr := io.ReadFull(connection, greeting); readErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
associate := make([]byte, 10)
|
|
||||||
if _, readErr := io.ReadFull(connection, associate); readErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port
|
|
||||||
response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)}
|
|
||||||
if _, writeErr := connection.Write(response); writeErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.Copy(io.Discard, connection)
|
|
||||||
}()
|
|
||||||
|
|
||||||
return tcpListener.Addr().String(), func() {
|
|
||||||
_ = tcpListener.Close()
|
|
||||||
_ = udpConnection.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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...)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"vocat/internal/device"
|
|
||||||
"vocat/internal/modem"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -53,62 +44,3 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The AT terminal must present ERROR / +CME ERROR as a normal response, not as
|
|
||||||
// a 502. Before the CommandError branch was restored, every unsupported or
|
|
||||||
// SIM-less command was folded into "the device operation failed", hiding the
|
|
||||||
// real reason from the user.
|
|
||||||
func TestHandleATSurfacesCommandErrorAsResponse(t *testing.T) {
|
|
||||||
controller := fakeDeviceController{
|
|
||||||
entry: device.Device{ID: "dev1"},
|
|
||||||
atHandler: func(command string) (modem.Response, error) {
|
|
||||||
return modem.Response{}, &modem.CommandError{
|
|
||||||
Command: command,
|
|
||||||
Final: "+CME ERROR: 10",
|
|
||||||
Lines: []string{"+CME ERROR: 10"},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
request := httptest.NewRequest(
|
|
||||||
http.MethodPost,
|
|
||||||
"/api/devices/dev1/actions/at",
|
|
||||||
strings.NewReader(`{"cmd":"AT+CPIN?","timeout_ms":5000}`),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
if !server.handleAT(recorder, request, "dev1") {
|
|
||||||
t.Fatal("handleAT returned false")
|
|
||||||
}
|
|
||||||
if recorder.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200 (body=%s)", recorder.Code, recorder.Body.String())
|
|
||||||
}
|
|
||||||
data := decodeData(t, recorder)
|
|
||||||
response, _ := data["response"].(string)
|
|
||||||
if !strings.Contains(response, "+CME ERROR: 10") {
|
|
||||||
t.Fatalf("response = %q, want +CME ERROR text", response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleATMapsNonCommandErrorTo502(t *testing.T) {
|
|
||||||
controller := fakeDeviceController{
|
|
||||||
entry: device.Device{ID: "dev1"},
|
|
||||||
atErr: errors.New("transport wedged"),
|
|
||||||
}
|
|
||||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
request := httptest.NewRequest(
|
|
||||||
http.MethodPost,
|
|
||||||
"/api/devices/dev1/actions/at",
|
|
||||||
strings.NewReader(`{"cmd":"AT+CSQ"}`),
|
|
||||||
)
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
if !server.handleAT(recorder, request, "dev1") {
|
|
||||||
t.Fatal("handleAT returned false")
|
|
||||||
}
|
|
||||||
if recorder.Code != http.StatusBadGateway {
|
|
||||||
t.Fatalf("status = %d, want 502", recorder.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
|||||||
}, "\n"),
|
}, "\n"),
|
||||||
Time: run.FinishedAt, Task: task, Run: run,
|
Time: run.FinishedAt, Task: task, Run: run,
|
||||||
}
|
}
|
||||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
||||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||||
continue
|
continue
|
||||||
@@ -91,8 +91,6 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
|||||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||||
case "lark":
|
|
||||||
return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message))
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-125
@@ -142,9 +142,6 @@ func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
||||||
return true
|
return true
|
||||||
case "dashboard/host":
|
|
||||||
s.handleDashboardHost(w, r)
|
|
||||||
return true
|
|
||||||
case "devices":
|
case "devices":
|
||||||
return s.handleDevices(w, r)
|
return s.handleDevices(w, r)
|
||||||
case "devices/discovered":
|
case "devices/discovered":
|
||||||
@@ -242,14 +239,10 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
config := payload.toStoreDevice()
|
config := payload.toStoreDevice()
|
||||||
isNative410 := config.DeviceType == store.DeviceTypeWiFi410
|
|
||||||
// Newly added hardware starts fail-closed: RF is disabled immediately and
|
// Newly added hardware starts fail-closed: RF is disabled immediately and
|
||||||
// VoWiFi becomes the desired service on supported devices. Native 410
|
// VoWiFi becomes the desired service. Cellular registration is only
|
||||||
// uses its QMI UIM/DMS/NAS adapter; only cellular SMS remains unavailable.
|
// restored by the user's later airplane-mode-off action.
|
||||||
config.VoWiFiEnabled = true
|
config.VoWiFiEnabled = true
|
||||||
if isNative410 {
|
|
||||||
config.SMSEnabled = false
|
|
||||||
}
|
|
||||||
config.NetworkEnabled = false
|
config.NetworkEnabled = false
|
||||||
if !s.developerActive(r.Context()) {
|
if !s.developerActive(r.Context()) {
|
||||||
config.NetworkEnabled = false
|
config.NetworkEnabled = false
|
||||||
@@ -291,7 +284,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.vowifi != nil && config.VoWiFiEnabled {
|
if s.vowifi != nil {
|
||||||
if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
|
if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
|
||||||
s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err)
|
s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err)
|
||||||
}
|
}
|
||||||
@@ -351,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{}}})
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": []any{}}})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// This endpoint backs the add-device dialog. Always perform a new physical
|
devices := s.devices.List()
|
||||||
// scan instead of serving Manager.List(), which intentionally retains
|
|
||||||
// unplugged configured devices so the main device list can show them offline.
|
|
||||||
devices, err := s.devices.Discover(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
s.writeDeviceError(w, err)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
configured, err := s.store.ListDevices(r.Context())
|
configured, err := s.store.ListDevices(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
@@ -366,9 +352,6 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
result := make([]map[string]any, 0, len(devices))
|
result := make([]map[string]any, 0, len(devices))
|
||||||
for _, entry := range devices {
|
for _, entry := range devices {
|
||||||
if !entry.Discovered {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
candidate := entry.Candidate
|
candidate := entry.Candidate
|
||||||
atPorts := make([]string, 0, len(candidate.Ports))
|
atPorts := make([]string, 0, len(candidate.Ports))
|
||||||
for _, port := range candidate.Ports {
|
for _, port := range candidate.Ports {
|
||||||
@@ -390,7 +373,6 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
|||||||
result = append(result, map[string]any{
|
result = append(result, map[string]any{
|
||||||
"hardware_kind": candidate.HardwareKind,
|
"hardware_kind": candidate.HardwareKind,
|
||||||
"reader_name": candidate.ReaderName,
|
"reader_name": candidate.ReaderName,
|
||||||
"device_type": discoveredDeviceType(candidate),
|
|
||||||
"discovery_key": entry.ID,
|
"discovery_key": entry.ID,
|
||||||
"control_path": controlPath,
|
"control_path": controlPath,
|
||||||
"net_interface": candidate.NetworkInterface,
|
"net_interface": candidate.NetworkInterface,
|
||||||
@@ -520,10 +502,6 @@ func (s *Server) handleDevicePath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry, physicalID, physicalPresent := s.physicalForConfig(config)
|
entry, physicalID, physicalPresent := s.physicalForConfig(config)
|
||||||
if config.DeviceType == store.DeviceTypeWiFi410 && native410UnsupportedOperation(tail) {
|
|
||||||
writeError(w, http.StatusNotImplemented, "device_feature_unsupported", "this feature is not supported by the native OpenStick 410 backend")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 {
|
if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 {
|
||||||
operation := strings.Join(tail, "/")
|
operation := strings.Join(tail, "/")
|
||||||
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
|
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
|
||||||
@@ -675,14 +653,6 @@ func (s *Server) handleDevicePath(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func native410UnsupportedOperation(tail []string) bool {
|
|
||||||
if len(tail) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
operation := strings.Join(tail, "/")
|
|
||||||
return tail[0] == "calls" || operation == "actions/reboot"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUSBNetMode(w http.ResponseWriter, r *http.Request, physicalID string) bool {
|
func (s *Server) handleUSBNetMode(w http.ResponseWriter, r *http.Request, physicalID string) bool {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@@ -1059,26 +1029,6 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
response, err := s.devices.ExecuteAT(ctx, id, command)
|
response, err := s.devices.ExecuteAT(ctx, id, command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var commandErr *modem.CommandError
|
|
||||||
if errors.As(err, &commandErr) {
|
|
||||||
// The modem answered with ERROR / +CME ERROR. An AT terminal must
|
|
||||||
// surface that text (including the CME detail) as a normal response;
|
|
||||||
// folding it into a 502 hides the real reason from the user.
|
|
||||||
text := strings.Join(commandErr.Lines, "\n")
|
|
||||||
if text != "" {
|
|
||||||
text += "\n"
|
|
||||||
}
|
|
||||||
text += commandErr.Final
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"data": map[string]any{
|
|
||||||
"response": text,
|
|
||||||
"final": commandErr.Final,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"urcs": []string{},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
s.writeDeviceError(w, err)
|
s.writeDeviceError(w, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1445,9 +1395,9 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
|||||||
case errors.Is(err, context.Canceled):
|
case errors.Is(err, context.Canceled):
|
||||||
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
||||||
default:
|
default:
|
||||||
// Preserve the hardware failure reason in the operator-visible log while
|
// Device errors may echo an AT command. Authentication commands can
|
||||||
// keeping AT payloads and long APDU material out of it.
|
// contain APN credentials, so keep raw errors out of logs and responses.
|
||||||
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
|
s.logger.Warn("device operation failed")
|
||||||
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1522,13 +1472,7 @@ func physicalMatchesConfig(entry device.Device, config store.Device) bool {
|
|||||||
return config.ModemIMEI == entry.Snapshot.IMEI
|
return config.ModemIMEI == entry.Snapshot.IMEI
|
||||||
}
|
}
|
||||||
if config.USBPath != "" && candidate.USBPath != "" {
|
if config.USBPath != "" && candidate.USBPath != "" {
|
||||||
if config.USBPath == candidate.USBPath {
|
return config.USBPath == candidate.USBPath
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Sysfs paths may be stored through /sys/class symlinks while a
|
|
||||||
// subsequent discovery returns the resolved device path. Keep checking
|
|
||||||
// the selected AT/QMI nodes instead of rejecting a modem whose physical
|
|
||||||
// path spelling changed but whose control plane is unchanged.
|
|
||||||
}
|
}
|
||||||
// Control and serial device nodes are allocation-order dependent. They are
|
// Control and serial device nodes are allocation-order dependent. They are
|
||||||
// only legacy fallbacks when no physical USB path or readable IMEI exists.
|
// only legacy fallbacks when no physical USB path or readable IMEI exists.
|
||||||
@@ -1710,60 +1654,56 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
|||||||
enabled, _ := extra["enabled"].(bool)
|
enabled, _ := extra["enabled"].(bool)
|
||||||
active, _ := extra["active"].(bool)
|
active, _ := extra["active"].(bool)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"device_id": runtime.DeviceID,
|
"device_id": runtime.DeviceID,
|
||||||
"phase": runtime.Phase,
|
"phase": runtime.Phase,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"active": active,
|
"active": active,
|
||||||
"carrier_profile": extra["carrier_profile"],
|
"dataplane_mode": runtime.DataplaneMode,
|
||||||
"carrier_profile_from": extra["carrier_profile_from"],
|
"iccid": runtime.ICCID,
|
||||||
"dataplane_mode": runtime.DataplaneMode,
|
"imsi": runtime.IMSI,
|
||||||
"iccid": runtime.ICCID,
|
"sim_ready": runtime.SIMReady,
|
||||||
"imsi": runtime.IMSI,
|
"access_ready": runtime.AccessReady,
|
||||||
"sim_ready": runtime.SIMReady,
|
"tunnel_ready": runtime.TunnelReady,
|
||||||
"access_ready": runtime.AccessReady,
|
"ims_ready": runtime.IMSReady,
|
||||||
"tunnel_ready": runtime.TunnelReady,
|
"sms_ready": runtime.SMSReady,
|
||||||
"ims_ready": runtime.IMSReady,
|
"reg_status": runtime.RegStatus,
|
||||||
"sms_ready": runtime.SMSReady,
|
"reg_status_text": runtime.RegStatusText,
|
||||||
"reg_status": runtime.RegStatus,
|
"network_mode": runtime.NetworkMode,
|
||||||
"reg_status_text": runtime.RegStatusText,
|
"local_phone": runtime.LocalPhone,
|
||||||
"network_mode": runtime.NetworkMode,
|
"phone_number_source": runtime.PhoneNumberSource,
|
||||||
"local_phone": runtime.LocalPhone,
|
"last_error_class": runtime.LastErrorClass,
|
||||||
"phone_number_source": runtime.PhoneNumberSource,
|
"last_error": runtime.LastError,
|
||||||
"last_error_class": runtime.LastErrorClass,
|
"last_reason": runtime.LastReason,
|
||||||
"last_error": runtime.LastError,
|
"updated_at": runtime.UpdatedAt,
|
||||||
"last_reason": runtime.LastReason,
|
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||||
"updated_at": runtime.UpdatedAt,
|
"imscore": rawJSONObject(runtime.IMSCore),
|
||||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
"smsip": rawJSONObject(runtime.SMSIP),
|
||||||
"imscore": rawJSONObject(runtime.IMSCore),
|
|
||||||
"smsip": rawJSONObject(runtime.SMSIP),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"device_id": runtime.DeviceID,
|
"device_id": runtime.DeviceID,
|
||||||
"phase": string(runtime.Phase),
|
"phase": string(runtime.Phase),
|
||||||
"enabled": runtime.Enabled,
|
"enabled": runtime.Enabled,
|
||||||
"active": runtime.Active,
|
"active": runtime.Active,
|
||||||
"carrier_profile": runtime.CarrierProfile,
|
"dataplane_mode": runtime.DataplaneMode,
|
||||||
"carrier_profile_from": runtime.CarrierProfileFrom,
|
"iccid": runtime.ICCID,
|
||||||
"dataplane_mode": runtime.DataplaneMode,
|
"imsi": runtime.IMSI,
|
||||||
"iccid": runtime.ICCID,
|
"sim_ready": runtime.SIMReady,
|
||||||
"imsi": runtime.IMSI,
|
"access_ready": runtime.AccessReady,
|
||||||
"sim_ready": runtime.SIMReady,
|
"tunnel_ready": runtime.TunnelReady,
|
||||||
"access_ready": runtime.AccessReady,
|
"ims_ready": runtime.IMSReady,
|
||||||
"tunnel_ready": runtime.TunnelReady,
|
"sms_ready": runtime.SMSReady,
|
||||||
"ims_ready": runtime.IMSReady,
|
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||||
"sms_ready": runtime.SMSReady,
|
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
"network_mode": "Wi-Fi",
|
||||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
"local_phone": runtime.PhoneNumber,
|
||||||
"network_mode": "Wi-Fi",
|
"phone_number_source": runtime.PhoneNumberSource,
|
||||||
"local_phone": runtime.PhoneNumber,
|
"last_error_class": runtime.LastErrorClass,
|
||||||
"phone_number_source": runtime.PhoneNumberSource,
|
"last_error": runtime.LastError,
|
||||||
"last_error_class": runtime.LastErrorClass,
|
"last_reason": runtime.LastReason,
|
||||||
"last_error": runtime.LastError,
|
"updated_at": runtime.UpdatedAt,
|
||||||
"last_reason": runtime.LastReason,
|
|
||||||
"updated_at": runtime.UpdatedAt,
|
|
||||||
"tunnel": map[string]any{
|
"tunnel": map[string]any{
|
||||||
"established": runtime.TunnelReady,
|
"established": runtime.TunnelReady,
|
||||||
"name": runtime.TunnelName,
|
"name": runtime.TunnelName,
|
||||||
@@ -1937,8 +1877,6 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
|||||||
config.NetworkEnabled = false
|
config.NetworkEnabled = false
|
||||||
config.SMSEnabled = true
|
config.SMSEnabled = true
|
||||||
config.VoWiFiEnabled = true
|
config.VoWiFiEnabled = true
|
||||||
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
|
||||||
config.DeviceType = store.DeviceTypeDJI4G
|
|
||||||
}
|
}
|
||||||
if config.Interface == "" {
|
if config.Interface == "" {
|
||||||
config.Interface = candidate.NetworkInterface
|
config.Interface = candidate.NetworkInterface
|
||||||
@@ -1963,16 +1901,6 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoveredDeviceType(candidate modem.Candidate) string {
|
|
||||||
if candidate.HardwareKind == "pcsc" {
|
|
||||||
return store.DeviceTypeUSBSIMReader
|
|
||||||
}
|
|
||||||
if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
|
||||||
return store.DeviceTypeDJI4G
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,29 +31,6 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
|
|||||||
return envelope.Data
|
return envelope.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNative410UnsupportedOperations(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
path []string
|
|
||||||
unsupported bool
|
|
||||||
}{
|
|
||||||
{path: []string{"esim"}},
|
|
||||||
{path: []string{"esim", "profiles"}},
|
|
||||||
{path: []string{"vowifi"}},
|
|
||||||
{path: []string{"vowifi", "actions", "reconnect"}},
|
|
||||||
{path: []string{"calls"}, unsupported: true},
|
|
||||||
{path: []string{"actions", "reboot"}, unsupported: true},
|
|
||||||
{path: []string{"actions", "refresh"}},
|
|
||||||
{path: []string{"actions", "at"}},
|
|
||||||
{path: []string{"flight-mode"}},
|
|
||||||
{path: []string{"operator_selection"}},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
if got := native410UnsupportedOperation(test.path); got != test.unsupported {
|
|
||||||
t.Errorf("native410UnsupportedOperation(%v) = %v, want %v", test.path, got, test.unsupported)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseModemAPNProfiles(t *testing.T) {
|
func TestParseModemAPNProfiles(t *testing.T) {
|
||||||
profiles := parseModemAPNProfiles([]string{
|
profiles := parseModemAPNProfiles([]string{
|
||||||
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
|
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
|
||||||
@@ -140,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) {
|
func TestFindDiscoveredDevicePrefersPhysicalIdentityOverSerialAlias(t *testing.T) {
|
||||||
alias := "/dev/serial/by-id/usb-Android_Android-if02-port0"
|
alias := "/dev/serial/by-id/usb-Android_Android-if02-port0"
|
||||||
devices := []device.Device{
|
devices := []device.Device{
|
||||||
|
|||||||
@@ -6,28 +6,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
"vocat/internal/modem"
|
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFillConfigFromPhysicalClassifiesDJI4G(t *testing.T) {
|
|
||||||
config := store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}
|
|
||||||
entry := device.Device{Candidate: modem.Candidate{
|
|
||||||
VendorID: "2ca3",
|
|
||||||
ProductID: "4006",
|
|
||||||
}}
|
|
||||||
|
|
||||||
fillConfigFromPhysical(&config, entry)
|
|
||||||
|
|
||||||
if config.DeviceType != store.DeviceTypeDJI4G {
|
|
||||||
t.Fatalf("device type = %q, want %q", config.DeviceType, store.DeviceTypeDJI4G)
|
|
||||||
}
|
|
||||||
if got := discoveredDeviceType(entry.Candidate); got != store.DeviceTypeDJI4G {
|
|
||||||
t.Fatalf("discovered device type = %q, want %q", got, store.DeviceTypeDJI4G)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||||
database, err := store.Open(context.Background(), ":memory:")
|
database, err := store.Open(context.Background(), ":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/vowifi"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func esimUnavailable(w http.ResponseWriter) {
|
func esimUnavailable(w http.ResponseWriter) {
|
||||||
@@ -401,41 +400,15 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
|
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
endMaintenance := func() {}
|
|
||||||
if maintenance, ok := s.vowifi.(VoWiFiMaintenanceController); ok {
|
|
||||||
if err := maintenance.BeginMaintenance(configuredID); err != nil {
|
|
||||||
s.writeDeviceError(w, fmt.Errorf("prepare VoWiFi for profile switch: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
released := false
|
|
||||||
endMaintenance = func() {
|
|
||||||
if !released {
|
|
||||||
released = true
|
|
||||||
maintenance.EndMaintenance(configuredID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer endMaintenance()
|
|
||||||
}
|
|
||||||
// A live VoWiFi runtime owns the SIM/QMI session while AKA, IMS and SMS are
|
|
||||||
// active. Tear it down before touching flight mode or the ISD-R logical
|
|
||||||
// channel; otherwise native-WWAN devices wait on the QMI lease until the HTTP
|
|
||||||
// request times out. This only changes the runtime desired state. The saved
|
|
||||||
// per-ICCID policy is left intact and the target profile's policy is restored
|
|
||||||
// after the verified switch below.
|
|
||||||
if err := s.quiesceVoWiFiForProfileSwitch(r.Context(), configuredID); err != nil {
|
|
||||||
s.writeDeviceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Profile operations run with RF disabled. The eUICC remains accessible in
|
// Profile operations run with RF disabled. The eUICC remains accessible in
|
||||||
// CFUN=4. Devices that consume the requested eUICC REFRESH stay online;
|
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
|
||||||
// older AT modems enter the reset recovery path and reapply CFUN=4 when the
|
// back after the mandatory modem reset.
|
||||||
// port returns.
|
|
||||||
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
|
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
|
||||||
s.writeDeviceError(w, err)
|
s.writeDeviceError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A confirmed profile switch always includes a live ICCID read and may also
|
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
|
||||||
// include the EC20 reset fallback, so it can exceed the ordinary deadline.
|
// which normally takes longer than the server's ordinary response deadline.
|
||||||
controller := http.NewResponseController(w)
|
controller := http.NewResponseController(w)
|
||||||
_ = controller.SetWriteDeadline(time.Time{})
|
_ = controller.SetWriteDeadline(time.Time{})
|
||||||
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
|
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
|
||||||
@@ -481,10 +454,6 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
|||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The target profile is now active and its persisted policy has replaced the
|
|
||||||
// old runtime configuration. Allow reconciliation again before requesting
|
|
||||||
// the target profile's desired VoWiFi state.
|
|
||||||
endMaintenance()
|
|
||||||
canRestoreFlightImmediately := s.vowifi == nil
|
canRestoreFlightImmediately := s.vowifi == nil
|
||||||
if s.vowifi != nil {
|
if s.vowifi != nil {
|
||||||
state, stateErr := s.vowifi.State(configuredID)
|
state, stateErr := s.vowifi.State(configuredID)
|
||||||
@@ -515,40 +484,6 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
|||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) quiesceVoWiFiForProfileSwitch(ctx context.Context, configuredID string) error {
|
|
||||||
if s.vowifi == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
state, err := s.vowifi.State(configuredID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("stop VoWiFi before switching profile: %w", err)
|
|
||||||
}
|
|
||||||
if !state.Enabled && !state.Active && state.Phase == vowifi.PhaseIdle {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if _, err := s.vowifi.RequestEnabled(configuredID, false); err != nil {
|
|
||||||
return fmt.Errorf("stop VoWiFi before switching profile: %w", err)
|
|
||||||
}
|
|
||||||
waitContext, cancel := context.WithTimeout(ctx, 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
state, err = s.vowifi.State(configuredID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wait for VoWiFi to stop before switching profile: %w", err)
|
|
||||||
}
|
|
||||||
if !state.Enabled && !state.Active && state.Phase == vowifi.PhaseIdle {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-waitContext.Done():
|
|
||||||
return fmt.Errorf("wait for VoWiFi to stop before switching profile: %w", waitContext.Err())
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
|
func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
|
||||||
if s.devices == nil {
|
if s.devices == nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
|
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, auth.ErrInvalidCredentials):
|
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
|
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
|
||||||
case errors.Is(err, auth.ErrEmptyPassword):
|
case strings.Contains(err.Error(), "between 6 and 1024"):
|
||||||
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
|
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
|
||||||
case strings.Contains(err.Error(), "must differ"):
|
case strings.Contains(err.Error(), "must differ"):
|
||||||
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
|
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !requireMethod(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !requireMethod(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := s.store.Ready(ctx); err != nil {
|
|
||||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ready"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMetrics exposes only process-level, non-identifying Prometheus data.
|
|
||||||
// Device IDs, SIM identities, phone numbers and proxy information never enter
|
|
||||||
// this unauthenticated endpoint.
|
|
||||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !requireMethod(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ready := 0
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
|
||||||
if s.store.Ready(ctx) == nil {
|
|
||||||
ready = 1
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
fmt.Fprint(w, "# HELP vocat_up Whether the process is running.\n# TYPE vocat_up gauge\nvocat_up 1\n")
|
|
||||||
fmt.Fprintf(w, "# HELP vocat_ready Whether the database is ready.\n# TYPE vocat_ready gauge\nvocat_ready %d\n", ready)
|
|
||||||
fmt.Fprintf(w, "# HELP vocat_uptime_seconds Process uptime.\n# TYPE vocat_uptime_seconds counter\nvocat_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())
|
|
||||||
fmt.Fprintf(w, "# HELP vocat_go_goroutines Current Go goroutines.\n# TYPE vocat_go_goroutines gauge\nvocat_go_goroutines %d\n", runtime.NumGoroutine())
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOperationalHealthEndpointsAreAnonymousAndNonIdentifying(t *testing.T) {
|
|
||||||
app := newTestApplication(t)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
path string
|
|
||||||
contentType string
|
|
||||||
contains string
|
|
||||||
}{
|
|
||||||
{path: "/healthz", contentType: "application/json", contains: `"status":"ok"`},
|
|
||||||
{path: "/readyz", contentType: "application/json", contains: `"status":"ready"`},
|
|
||||||
{path: "/metrics", contentType: "text/plain", contains: "vocat_ready 1"},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.path, func(t *testing.T) {
|
|
||||||
response, err := app.client.Get(app.server.URL + test.path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
body, err := io.ReadAll(response.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if response.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body = %s", response.StatusCode, body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(response.Header.Get("Content-Type"), test.contentType) {
|
|
||||||
t.Fatalf("Content-Type = %q", response.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(body), test.contains) {
|
|
||||||
t.Fatalf("body = %q, want %q", body, test.contains)
|
|
||||||
}
|
|
||||||
for _, forbidden := range []string{"imsi", "iccid", "msisdn", "proxy", "device_id"} {
|
|
||||||
if strings.Contains(strings.ToLower(string(body)), forbidden) {
|
|
||||||
t.Fatalf("body exposes forbidden label %q: %s", forbidden, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOperationalHealthEndpointsRejectPOST(t *testing.T) {
|
|
||||||
app := newTestApplication(t)
|
|
||||||
for _, path := range []string{"/healthz", "/readyz", "/metrics"} {
|
|
||||||
request, err := http.NewRequest(http.MethodPost, app.server.URL+path, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
response, err := app.client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
response.Body.Close()
|
|
||||||
if response.StatusCode != http.StatusMethodNotAllowed {
|
|
||||||
t.Fatalf("%s status = %d, want %d", path, response.StatusCode, http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hostStaticInfo describes hardware identities that do not change while the
|
|
||||||
// process runs, so they are probed once and cached.
|
|
||||||
type hostStaticInfo struct {
|
|
||||||
CPUModel string `json:"cpu_model"`
|
|
||||||
BoardModel string `json:"board_model"`
|
|
||||||
MemoryModel string `json:"memory_model"`
|
|
||||||
DiskModel string `json:"disk_model"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostPerfSnapshot is one rendered read of host utilization for the dashboard.
|
|
||||||
type hostPerfSnapshot struct {
|
|
||||||
CPUPercent float64 `json:"cpu_percent"`
|
|
||||||
MemoryPercent float64 `json:"memory_percent"`
|
|
||||||
MemoryUsed uint64 `json:"memory_used_bytes"`
|
|
||||||
MemoryTotal uint64 `json:"memory_total_bytes"`
|
|
||||||
DiskPercent float64 `json:"disk_percent"`
|
|
||||||
DiskUsed uint64 `json:"disk_used_bytes"`
|
|
||||||
DiskTotal uint64 `json:"disk_total_bytes"`
|
|
||||||
NetRxBps float64 `json:"net_rx_bps"`
|
|
||||||
NetTxBps float64 `json:"net_tx_bps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostCPUTimes is one cumulative /proc/stat reading: idle already includes
|
|
||||||
// iowait, total sums every other column (guest time is already folded into
|
|
||||||
// user/nice and therefore excluded).
|
|
||||||
type hostCPUTimes struct {
|
|
||||||
idle uint64
|
|
||||||
total uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
// hostStatsMinGap keeps back-to-back polls from dividing a handful of
|
|
||||||
// jiffies by a few milliseconds; the previous rate is reused instead.
|
|
||||||
hostStatsMinGap = 300 * time.Millisecond
|
|
||||||
// hostStatsMaxGap mirrors liveNetMaxGap: a gap past this means the tab was
|
|
||||||
// closed or the browser was hidden; re-baseline instead of averaging a
|
|
||||||
// long dead interval.
|
|
||||||
hostStatsMaxGap = 15 * time.Second
|
|
||||||
// hostStatsFirstSample is how long the very first request blocks so CPU
|
|
||||||
// and network readings have a real interval to average over. It must
|
|
||||||
// exceed hostStatsMinGap so the re-read survives the min-gap guard below.
|
|
||||||
hostStatsFirstSample = 400 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
// hostStatsSampler derives live host utilization from cumulative kernel
|
|
||||||
// counters. Like liveNetTracker it is driven on demand by dashboard polling,
|
|
||||||
// so no background goroutine is required.
|
|
||||||
type hostStatsSampler struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
static *hostStaticInfo
|
|
||||||
sampledAt time.Time
|
|
||||||
prevCPU hostCPUTimes
|
|
||||||
prevNetRx uint64
|
|
||||||
prevNetTx uint64
|
|
||||||
lastCPU float64
|
|
||||||
lastRxBps float64
|
|
||||||
lastTxBps float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHostStatsSampler() *hostStatsSampler {
|
|
||||||
return &hostStatsSampler{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDashboardHost serves the dashboard host card: static hardware identity
|
|
||||||
// plus live utilization. Both halves are cheap reads of /proc and /sys.
|
|
||||||
func (s *Server) handleDashboardHost(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !requireMethod(w, r, http.MethodGet) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
|
||||||
"host": s.hostStats.info(),
|
|
||||||
"perf": s.hostStats.perf(),
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
|
|
||||||
// info returns the cached static hardware description, probing it on first use.
|
|
||||||
func (s *hostStatsSampler) info() hostStaticInfo {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.static == nil {
|
|
||||||
info := probeHostStatic()
|
|
||||||
s.static = &info
|
|
||||||
}
|
|
||||||
return *s.static
|
|
||||||
}
|
|
||||||
|
|
||||||
// perf renders one utilization snapshot. CPU and network rates need a baseline,
|
|
||||||
// so the first-ever call takes a short inline second reading; later calls
|
|
||||||
// average against the previous request.
|
|
||||||
func (s *hostStatsSampler) perf() hostPerfSnapshot {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
cpu, cpuOK := readHostCPUTimes()
|
|
||||||
rx, tx, netOK := readHostNetTotals()
|
|
||||||
|
|
||||||
// No usable baseline yet (first request, or the tab was hidden past the
|
|
||||||
// max gap): establish one, then re-read after a short interval so the
|
|
||||||
// first dashboard paint already reports real numbers.
|
|
||||||
needBaseline := s.sampledAt.IsZero() || now.Sub(s.sampledAt) > hostStatsMaxGap
|
|
||||||
if needBaseline && (cpuOK || netOK) {
|
|
||||||
s.sampledAt = now
|
|
||||||
if cpuOK {
|
|
||||||
s.prevCPU = cpu
|
|
||||||
}
|
|
||||||
if netOK {
|
|
||||||
s.prevNetRx, s.prevNetTx = rx, tx
|
|
||||||
}
|
|
||||||
time.Sleep(hostStatsFirstSample)
|
|
||||||
now = time.Now()
|
|
||||||
if next, ok := readHostCPUTimes(); ok {
|
|
||||||
cpu, cpuOK = next, true
|
|
||||||
}
|
|
||||||
if nextRx, nextTx, ok := readHostNetTotals(); ok {
|
|
||||||
rx, tx, netOK = nextRx, nextTx, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
memPercent, memUsed, memTotal := readHostMemory()
|
|
||||||
diskPercent, diskUsed, diskTotal := readHostDisk()
|
|
||||||
|
|
||||||
gap := now.Sub(s.sampledAt)
|
|
||||||
if gap >= hostStatsMinGap && (cpuOK || netOK) {
|
|
||||||
if cpuOK {
|
|
||||||
if busyDelta, totalDelta := cpuDelta(s.prevCPU, cpu); totalDelta > 0 {
|
|
||||||
s.lastCPU = clampPercent(float64(busyDelta) * 100 / float64(totalDelta))
|
|
||||||
}
|
|
||||||
s.prevCPU = cpu
|
|
||||||
}
|
|
||||||
if netOK {
|
|
||||||
// Counter resets (interface flap) must not produce a giant spike.
|
|
||||||
if rx >= s.prevNetRx {
|
|
||||||
s.lastRxBps = float64(rx-s.prevNetRx) / gap.Seconds()
|
|
||||||
} else {
|
|
||||||
s.lastRxBps = 0
|
|
||||||
}
|
|
||||||
if tx >= s.prevNetTx {
|
|
||||||
s.lastTxBps = float64(tx-s.prevNetTx) / gap.Seconds()
|
|
||||||
} else {
|
|
||||||
s.lastTxBps = 0
|
|
||||||
}
|
|
||||||
s.prevNetRx, s.prevNetTx = rx, tx
|
|
||||||
}
|
|
||||||
s.sampledAt = now
|
|
||||||
}
|
|
||||||
|
|
||||||
return hostPerfSnapshot{
|
|
||||||
CPUPercent: s.lastCPU,
|
|
||||||
MemoryPercent: memPercent,
|
|
||||||
MemoryUsed: memUsed,
|
|
||||||
MemoryTotal: memTotal,
|
|
||||||
DiskPercent: diskPercent,
|
|
||||||
DiskUsed: diskUsed,
|
|
||||||
DiskTotal: diskTotal,
|
|
||||||
NetRxBps: s.lastRxBps,
|
|
||||||
NetTxBps: s.lastTxBps,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpuDelta returns the busy and total jiffies elapsed between two cumulative
|
|
||||||
// readings. A backwards counter (theoretically impossible for /proc/stat)
|
|
||||||
// reports zero rather than wrapping.
|
|
||||||
func cpuDelta(prev, next hostCPUTimes) (busy, total uint64) {
|
|
||||||
if next.total <= prev.total || next.idle < prev.idle {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
totalDelta := next.total - prev.total
|
|
||||||
idleDelta := next.idle - prev.idle
|
|
||||||
if idleDelta >= totalDelta {
|
|
||||||
return 0, totalDelta
|
|
||||||
}
|
|
||||||
return totalDelta - idleDelta, totalDelta
|
|
||||||
}
|
|
||||||
|
|
||||||
func clampPercent(value float64) float64 {
|
|
||||||
switch {
|
|
||||||
case value < 0:
|
|
||||||
return 0
|
|
||||||
case value > 100:
|
|
||||||
return 100
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostNetIgnoredPrefixes are virtual interface name prefixes whose counters
|
|
||||||
// would double-count physical traffic (bridges, tunnels, vocat's own links) or
|
|
||||||
// carry no real host traffic at all.
|
|
||||||
var hostNetIgnoredPrefixes = []string{
|
|
||||||
"lo", "br-", "docker", "veth", "virbr", "vmnet", "vboxnet",
|
|
||||||
"ip6tnl", "ip6gre", "sit", "gre", "gretap", "erspan",
|
|
||||||
"tun", "tap", "utun", "vocat", "wg", "zt", "tailscale",
|
|
||||||
"ifb", "bond", "vlan", "macvlan", "dummy", "lxc", "cali", "flannel", "cni",
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostNetInterfaceCounted reports whether an interface's byte counters feed the
|
|
||||||
// host-level upload/download rates.
|
|
||||||
func hostNetInterfaceCounted(name string) bool {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, prefix := range hostNetIgnoredPrefixes {
|
|
||||||
if strings.HasPrefix(name, prefix) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseNetDevCounters sums rx/tx bytes across counted interfaces in
|
|
||||||
// /proc/net/dev content.
|
|
||||||
func parseNetDevCounters(content string) (rx, tx uint64) {
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
|
||||||
name, rest, found := strings.Cut(line, ":")
|
|
||||||
if !found || !hostNetInterfaceCounted(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields := strings.Fields(rest)
|
|
||||||
if len(fields) < 9 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rxBytes, okRx := parseUint(fields[0])
|
|
||||||
txBytes, okTx := parseUint(fields[8])
|
|
||||||
if !okRx || !okTx {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rx += rxBytes
|
|
||||||
tx += txBytes
|
|
||||||
}
|
|
||||||
return rx, tx
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUint(text string) (uint64, bool) {
|
|
||||||
value, err := strconv.ParseUint(strings.TrimSpace(text), 10, 64)
|
|
||||||
return value, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCPUTimes parses the aggregate "cpu" line of /proc/stat.
|
|
||||||
func parseCPUTimes(line string) (hostCPUTimes, bool) {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
// cpu user nice system idle iowait irq softirq steal [guest guest_nice]
|
|
||||||
if len(fields) < 9 || fields[0] != "cpu" {
|
|
||||||
return hostCPUTimes{}, false
|
|
||||||
}
|
|
||||||
var times hostCPUTimes
|
|
||||||
for index, field := range fields[1:9] {
|
|
||||||
value, ok := parseUint(field)
|
|
||||||
if !ok {
|
|
||||||
return hostCPUTimes{}, false
|
|
||||||
}
|
|
||||||
times.total += value
|
|
||||||
if index == 3 || index == 4 { // idle + iowait
|
|
||||||
times.idle += value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return times, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseMeminfo extracts MemTotal and MemAvailable (bytes) from /proc/meminfo.
|
|
||||||
func parseMeminfo(content string) (total, available uint64, ok bool) {
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
|
||||||
key, rest, found := strings.Cut(line, ":")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var value uint64
|
|
||||||
switch strings.TrimSpace(key) {
|
|
||||||
case "MemTotal":
|
|
||||||
value, ok = parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB")))
|
|
||||||
if ok {
|
|
||||||
total = value * 1024
|
|
||||||
}
|
|
||||||
case "MemAvailable":
|
|
||||||
if value, parsed := parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB"))); parsed {
|
|
||||||
available = value * 1024
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total, available, total > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCPUInfoModel returns the x86-style "model name" from /proc/cpuinfo, or
|
|
||||||
// an empty string on ARM hosts that only carry CPU part numbers.
|
|
||||||
func parseCPUInfoModel(content string) string {
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
|
||||||
key, value, found := strings.Cut(line, ":")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch strings.TrimSpace(key) {
|
|
||||||
case "model name", "Model", "Hardware":
|
|
||||||
if model := strings.TrimSpace(value); model != "" {
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCPUInfoPart returns the first ARM "CPU part" hex identifier (e.g.
|
|
||||||
// 0xd03) and the number of processors listed.
|
|
||||||
func parseCPUInfoPart(content string) (part string, processors int) {
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
|
||||||
key, value, found := strings.Cut(line, ":")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch strings.TrimSpace(key) {
|
|
||||||
case "processor":
|
|
||||||
processors++
|
|
||||||
case "CPU part":
|
|
||||||
if part == "" {
|
|
||||||
part = strings.ToLower(strings.TrimSpace(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return part, processors
|
|
||||||
}
|
|
||||||
|
|
||||||
// armCPUPartNames maps ARM CPU part identifiers to marketing core names.
|
|
||||||
var armCPUPartNames = map[string]string{
|
|
||||||
"0xd03": "Cortex-A53",
|
|
||||||
"0xd04": "Cortex-A35",
|
|
||||||
"0xd05": "Cortex-A55",
|
|
||||||
"0xd06": "Cortex-A65",
|
|
||||||
"0xd07": "Cortex-A57",
|
|
||||||
"0xd08": "Cortex-A72",
|
|
||||||
"0xd09": "Cortex-A73",
|
|
||||||
"0xd0a": "Cortex-A75",
|
|
||||||
"0xd0b": "Cortex-A76",
|
|
||||||
"0xd0c": "Neoverse-N1",
|
|
||||||
"0xd0d": "Cortex-A77",
|
|
||||||
"0xd0e": "Cortex-A76AE",
|
|
||||||
"0xd40": "Neoverse-V1",
|
|
||||||
"0xd41": "Cortex-A78",
|
|
||||||
"0xd42": "Cortex-A78AE",
|
|
||||||
"0xd44": "Cortex-X1",
|
|
||||||
"0xd46": "Cortex-A510",
|
|
||||||
"0xd47": "Cortex-A710",
|
|
||||||
"0xd48": "Cortex-X2",
|
|
||||||
"0xd4b": "Cortex-A715",
|
|
||||||
"0xd4d": "Cortex-A520",
|
|
||||||
"0xd4e": "Cortex-X3",
|
|
||||||
}
|
|
||||||
|
|
||||||
// socVendorNames prettifies the vendor half of a device-tree compatible entry.
|
|
||||||
var socVendorNames = map[string]string{
|
|
||||||
"allwinner": "Allwinner",
|
|
||||||
"amlogic": "Amlogic",
|
|
||||||
"broadcom": "Broadcom",
|
|
||||||
"mediatek": "MediaTek",
|
|
||||||
"nvidia": "NVIDIA",
|
|
||||||
"qualcomm": "Qualcomm",
|
|
||||||
"raspberrypi": "Raspberry Pi",
|
|
||||||
"rockchip": "Rockchip",
|
|
||||||
"samsung": "Samsung",
|
|
||||||
"ti": "TI",
|
|
||||||
"xunlong": "Xunlong",
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCompatibleSoC extracts the SoC half of a device-tree compatible list
|
|
||||||
// (NUL-separated, most specific first): "xunlong,orangepi-zero3\0allwinner,
|
|
||||||
// sun50i-h618\0" yields "Allwinner sun50i-h618".
|
|
||||||
func parseCompatibleSoC(raw string) string {
|
|
||||||
entries := strings.FieldsFunc(raw, func(r rune) bool { return r == 0 || r == '\n' })
|
|
||||||
// The last entry is the least specific compatible, which on ARM boards is
|
|
||||||
// the SoC rather than the board.
|
|
||||||
for index := len(entries) - 1; index >= 0; index-- {
|
|
||||||
entry := strings.TrimSpace(entries[index])
|
|
||||||
vendor, soc, found := strings.Cut(entry, ",")
|
|
||||||
if !found || soc == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if pretty, ok := socVendorNames[strings.ToLower(vendor)]; ok {
|
|
||||||
vendor = pretty
|
|
||||||
} else {
|
|
||||||
vendor = strings.ToUpper(vendor[:1]) + vendor[1:]
|
|
||||||
}
|
|
||||||
return vendor + " " + soc
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// composeARMCPUModel renders e.g. "Allwinner sun50i-h618 · 4× Cortex-A53".
|
|
||||||
func composeARMCPUModel(soc, part string, processors int) string {
|
|
||||||
core := armCPUPartNames[part]
|
|
||||||
var result string
|
|
||||||
switch {
|
|
||||||
case soc != "" && core != "" && processors > 0:
|
|
||||||
result = soc + " · " + strconv.Itoa(processors) + "× " + core
|
|
||||||
case soc != "" && processors > 0:
|
|
||||||
result = soc + " · " + strconv.Itoa(processors) + "× CPU"
|
|
||||||
case soc != "" && core != "":
|
|
||||||
result = soc + " · " + core
|
|
||||||
default:
|
|
||||||
result = soc
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// skipHostDisk reports whether a /sys/block entry is a virtual device whose
|
|
||||||
// "model" would only clutter the host card.
|
|
||||||
func skipHostDisk(name string) bool {
|
|
||||||
for _, prefix := range []string{"loop", "ram", "zram", "sr", "nbd", "dm-", "md", "mtdblock", "ubi", "ubiblock"} {
|
|
||||||
if strings.HasPrefix(name, prefix) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDmidecodeMemory extracts a compact "8 GB DDR4 M471A1K43CB1-CRC" style
|
|
||||||
// description from `dmidecode -t 17` output, preferring the first populated
|
|
||||||
// slot. Empty when no installed module can be described.
|
|
||||||
func parseDmidecodeMemory(output string) string {
|
|
||||||
var size, memType, partNumber string
|
|
||||||
flush := func() string {
|
|
||||||
if size != "" && partNumber != "" {
|
|
||||||
return strings.TrimSpace(size + " " + memType + " " + partNumber)
|
|
||||||
}
|
|
||||||
if size != "" && memType != "" {
|
|
||||||
return strings.TrimSpace(size + " " + memType)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(output, "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "Memory Device") {
|
|
||||||
if composed := flush(); composed != "" {
|
|
||||||
return composed
|
|
||||||
}
|
|
||||||
size, memType, partNumber = "", "", ""
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, value, found := strings.Cut(trimmed, ":")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
switch strings.TrimSpace(key) {
|
|
||||||
case "Size":
|
|
||||||
if !strings.Contains(value, "No Module") && value != "" && value != "Unknown" {
|
|
||||||
size = value
|
|
||||||
}
|
|
||||||
case "Type":
|
|
||||||
if value != "Unknown" && value != "Other" && !strings.HasPrefix(value, "<OUT OF SPEC") {
|
|
||||||
memType = value
|
|
||||||
}
|
|
||||||
case "Part Number":
|
|
||||||
if value != "Unknown" && value != "None" && value != "" {
|
|
||||||
partNumber = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flush()
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
// probeHostStatic gathers the hardware identities shown on the dashboard host
|
|
||||||
// card. Every probe is best-effort: empty fields render as "—" in the SPA.
|
|
||||||
func probeHostStatic() hostStaticInfo {
|
|
||||||
return hostStaticInfo{
|
|
||||||
CPUModel: readHostCPUModel(),
|
|
||||||
BoardModel: readHostBoardModel(),
|
|
||||||
MemoryModel: readHostMemoryModel(),
|
|
||||||
DiskModel: readHostDiskModel(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostCPUModel prefers the x86-style "model name"; on ARM hosts it composes
|
|
||||||
// the device-tree SoC with the core count and Cortex part name.
|
|
||||||
func readHostCPUModel() string {
|
|
||||||
cpuinfo, err := os.ReadFile("/proc/cpuinfo")
|
|
||||||
if err == nil {
|
|
||||||
if model := parseCPUInfoModel(string(cpuinfo)); model != "" {
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
part, processors := parseCPUInfoPart(string(cpuinfo))
|
|
||||||
if processors == 0 {
|
|
||||||
processors = runtime.NumCPU()
|
|
||||||
}
|
|
||||||
soc := ""
|
|
||||||
if compatible, err := os.ReadFile("/proc/device-tree/compatible"); err == nil {
|
|
||||||
soc = parseCompatibleSoC(string(compatible))
|
|
||||||
}
|
|
||||||
if model := composeARMCPUModel(soc, part, processors); model != "" {
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runtime.GOARCH
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostBoardModel reads the device-tree model on ARM boards and the DMI
|
|
||||||
// board name on x86 machines.
|
|
||||||
func readHostBoardModel() string {
|
|
||||||
if model, err := os.ReadFile("/proc/device-tree/model"); err == nil {
|
|
||||||
if text := strings.TrimSpace(strings.TrimRight(string(model), "\x00")); text != "" {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dmiDir := "/sys/devices/virtual/dmi/id"
|
|
||||||
board := readSysfsTrimmed(filepath.Join(dmiDir, "board_name"))
|
|
||||||
vendor := readSysfsTrimmed(filepath.Join(dmiDir, "board_vendor"))
|
|
||||||
if board != "" && !isPlaceholderDMI(board) {
|
|
||||||
if vendor != "" && !isPlaceholderDMI(vendor) && !strings.Contains(strings.ToLower(board), strings.ToLower(vendor)) {
|
|
||||||
return vendor + " " + board
|
|
||||||
}
|
|
||||||
return board
|
|
||||||
}
|
|
||||||
if product := readSysfsTrimmed(filepath.Join(dmiDir, "product_name")); product != "" && !isPlaceholderDMI(product) {
|
|
||||||
return product
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPlaceholderDMI filters the well-known "we never filled this in" DMI
|
|
||||||
// strings so they do not surface as board models.
|
|
||||||
func isPlaceholderDMI(value string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "", "default string", "to be filled by o.e.m.", "to be filled by o.e.m", "none", "unknown", "n/a", "not specified", "system manufacturer":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostMemoryModel reports the installed DIMM description when dmidecode is
|
|
||||||
// available (typical on x86 NAS/PC hosts) and falls back to total capacity,
|
|
||||||
// which is all an ARM board exposes.
|
|
||||||
func readHostMemoryModel() string {
|
|
||||||
if path, err := exec.LookPath("dmidecode"); err == nil {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if output, err := exec.CommandContext(ctx, path, "-t", "17").Output(); err == nil {
|
|
||||||
if model := parseDmidecodeMemory(string(output)); model != "" {
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total, _, ok := readHostMemoryBytes(); ok {
|
|
||||||
return formatLiveBytes(float64(total))
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostDiskModel describes physical block devices, skipping virtual ones
|
|
||||||
// (loop, zram, device-mapper, mtd, optical). Multiple disks join with "; ".
|
|
||||||
func readHostDiskModel() string {
|
|
||||||
entries, err := os.ReadDir("/sys/block")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var disks []string
|
|
||||||
for _, entry := range entries {
|
|
||||||
name := entry.Name()
|
|
||||||
if skipHostDisk(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
base := filepath.Join("/sys/block", name)
|
|
||||||
sizeText := readSysfsTrimmed(filepath.Join(base, "size"))
|
|
||||||
sectors, ok := parseUint(sizeText)
|
|
||||||
if !ok || sectors == 0 {
|
|
||||||
// An empty card reader reports size 0 and tells us nothing.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
model := readSysfsTrimmed(filepath.Join(base, "device", "model"))
|
|
||||||
if model == "" {
|
|
||||||
// MMC/SD cards carry the product name instead of a model string.
|
|
||||||
model = readSysfsTrimmed(filepath.Join(base, "device", "name"))
|
|
||||||
}
|
|
||||||
if model == "" {
|
|
||||||
model = name
|
|
||||||
}
|
|
||||||
capacity := formatLiveBytes(float64(sectors) * 512)
|
|
||||||
disks = append(disks, model+" · "+capacity)
|
|
||||||
}
|
|
||||||
return strings.Join(disks, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSysfsTrimmed(path string) string {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(strings.TrimRight(string(raw), "\x00"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostCPUTimes reads the aggregate counters from /proc/stat.
|
|
||||||
func readHostCPUTimes() (hostCPUTimes, bool) {
|
|
||||||
raw, err := os.ReadFile("/proc/stat")
|
|
||||||
if err != nil {
|
|
||||||
return hostCPUTimes{}, false
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(raw), "\n") {
|
|
||||||
if strings.HasPrefix(line, "cpu ") {
|
|
||||||
return parseCPUTimes(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hostCPUTimes{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostMemoryBytes returns MemTotal and MemAvailable in bytes.
|
|
||||||
func readHostMemoryBytes() (total, available uint64, ok bool) {
|
|
||||||
raw, err := os.ReadFile("/proc/meminfo")
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
return parseMeminfo(string(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostMemory reports used/total bytes and the used percentage.
|
|
||||||
func readHostMemory() (percent float64, used, total uint64) {
|
|
||||||
total, available, ok := readHostMemoryBytes()
|
|
||||||
if !ok || total == 0 {
|
|
||||||
return 0, 0, 0
|
|
||||||
}
|
|
||||||
used = total - available
|
|
||||||
return clampPercent(float64(used) * 100 / float64(total)), used, total
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostDisk reports root filesystem usage the way df does: usable space is
|
|
||||||
// total minus reserved blocks, and the percentage is used/(used+available).
|
|
||||||
func readHostDisk() (percent float64, used, total uint64) {
|
|
||||||
var stat unix.Statfs_t
|
|
||||||
if err := unix.Statfs("/", &stat); err != nil || stat.Blocks == 0 {
|
|
||||||
return 0, 0, 0
|
|
||||||
}
|
|
||||||
blockSize := uint64(stat.Bsize)
|
|
||||||
total = stat.Blocks * blockSize
|
|
||||||
free := stat.Bfree * blockSize
|
|
||||||
available := stat.Bavail * blockSize
|
|
||||||
used = total - free
|
|
||||||
if denominator := used + available; denominator > 0 {
|
|
||||||
percent = clampPercent(float64(used) * 100 / float64(denominator))
|
|
||||||
}
|
|
||||||
return percent, used, total
|
|
||||||
}
|
|
||||||
|
|
||||||
// readHostNetTotals sums rx/tx counters across physical host interfaces.
|
|
||||||
func readHostNetTotals() (rx, tx uint64, ok bool) {
|
|
||||||
raw, err := os.ReadFile("/proc/net/dev")
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
rx, tx = parseNetDevCounters(string(raw))
|
|
||||||
return rx, tx, true
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package server
|
|
||||||
|
|
||||||
// Host statistics are only meaningful on the Linux deployment target; on other
|
|
||||||
// platforms every probe reports empty/zero and the dashboard renders "—".
|
|
||||||
|
|
||||||
func probeHostStatic() hostStaticInfo { return hostStaticInfo{} }
|
|
||||||
|
|
||||||
func readHostCPUTimes() (hostCPUTimes, bool) { return hostCPUTimes{}, false }
|
|
||||||
|
|
||||||
func readHostNetTotals() (uint64, uint64, bool) { return 0, 0, false }
|
|
||||||
|
|
||||||
func readHostMemory() (float64, uint64, uint64) { return 0, 0, 0 }
|
|
||||||
|
|
||||||
func readHostDisk() (float64, uint64, uint64) { return 0, 0, 0 }
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseCPUTimes(t *testing.T) {
|
|
||||||
times, ok := parseCPUTimes("cpu 38073 0 24013 6762971 3121 0 3019 0 0 0")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("parseCPUTimes rejected a valid cpu line")
|
|
||||||
}
|
|
||||||
wantTotal := uint64(38073 + 0 + 24013 + 6762971 + 3121 + 0 + 3019 + 0)
|
|
||||||
if times.total != wantTotal {
|
|
||||||
t.Fatalf("total = %d, want %d", times.total, wantTotal)
|
|
||||||
}
|
|
||||||
if wantIdle := uint64(6762971 + 3121); times.idle != wantIdle {
|
|
||||||
t.Fatalf("idle = %d, want %d", times.idle, wantIdle)
|
|
||||||
}
|
|
||||||
if _, ok := parseCPUTimes("cpu0 1 2 3 4 5 6 7 8"); ok {
|
|
||||||
t.Fatal("per-core line must not parse as the aggregate line")
|
|
||||||
}
|
|
||||||
if _, ok := parseCPUTimes("cpu 1 2 3"); ok {
|
|
||||||
t.Fatal("truncated cpu line must not parse")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCPUDelta(t *testing.T) {
|
|
||||||
prev := hostCPUTimes{idle: 100, total: 200}
|
|
||||||
next := hostCPUTimes{idle: 150, total: 300}
|
|
||||||
busy, total := cpuDelta(prev, next)
|
|
||||||
if busy != 50 || total != 100 {
|
|
||||||
t.Fatalf("cpuDelta = (%d, %d), want (50, 100)", busy, total)
|
|
||||||
}
|
|
||||||
if busy, total := cpuDelta(next, prev); busy != 0 || total != 0 {
|
|
||||||
t.Fatalf("backwards counters must report zero, got (%d, %d)", busy, total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseMeminfo(t *testing.T) {
|
|
||||||
content := "MemTotal: 2040424 kB\nMemFree: 920864 kB\nMemAvailable: 1543480 kB\nBuffers: 315908 kB\n"
|
|
||||||
total, available, ok := parseMeminfo(content)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("parseMeminfo rejected valid content")
|
|
||||||
}
|
|
||||||
if total != 2040424*1024 {
|
|
||||||
t.Fatalf("total = %d, want %d", total, 2040424*1024)
|
|
||||||
}
|
|
||||||
if available != 1543480*1024 {
|
|
||||||
t.Fatalf("available = %d, want %d", available, 1543480*1024)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseNetDevCounters(t *testing.T) {
|
|
||||||
content := `Inter-| Receive | Transmit
|
|
||||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
|
||||||
lo: 10 1 0 0 0 0 0 0 20 2 0 0 0 0 0 0
|
|
||||||
eth0: 100 1 0 0 0 0 0 0 200 2 0 0 0 0 0 0
|
|
||||||
br-lan: 1000 1 0 0 0 0 0 0 2000 2 0 0 0 0 0 0
|
|
||||||
utun: 300 1 0 0 0 0 0 0 400 2 0 0 0 0 0 0
|
|
||||||
vocat50a684ceb0: 500 1 0 0 0 0 0 0 600 2 0 0 0 0 0 0
|
|
||||||
wwan0: 700 1 0 0 0 0 0 0 800 2 0 0 0 0 0 0
|
|
||||||
`
|
|
||||||
rx, tx := parseNetDevCounters(content)
|
|
||||||
// Only eth0 and wwan0 count; lo, br-lan, utun and vocat are virtual.
|
|
||||||
if rx != 800 || tx != 1000 {
|
|
||||||
t.Fatalf("rx,tx = %d,%d, want 800,1000", rx, tx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostNetInterfaceCounted(t *testing.T) {
|
|
||||||
counted := []string{"eth0", "eth1", "wwan0", "usb0", "wlan0", "enp3s0", "pppoe-wan"}
|
|
||||||
for _, name := range counted {
|
|
||||||
if !hostNetInterfaceCounted(name) {
|
|
||||||
t.Fatalf("%s should be counted", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
skipped := []string{"lo", "br-lan", "docker0", "veth123", "ip6tnl0", "sit0", "utun", "vocat50a684ceb0", "wg0", "tun0", "tailscale0", ""}
|
|
||||||
for _, name := range skipped {
|
|
||||||
if hostNetInterfaceCounted(name) {
|
|
||||||
t.Fatalf("%s should be skipped", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCPUInfoModelX86(t *testing.T) {
|
|
||||||
content := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz\n"
|
|
||||||
if model := parseCPUInfoModel(content); model != "Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz" {
|
|
||||||
t.Fatalf("model = %q", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCPUInfoARM(t *testing.T) {
|
|
||||||
content := "processor\t: 0\nBogoMIPS\t: 48.00\nCPU implementer\t: 0x41\nCPU part\t: 0xd03\nprocessor\t: 1\nCPU part\t: 0xd03\n"
|
|
||||||
if model := parseCPUInfoModel(content); model != "" {
|
|
||||||
t.Fatalf("ARM cpuinfo must not report an x86 model name, got %q", model)
|
|
||||||
}
|
|
||||||
part, processors := parseCPUInfoPart(content)
|
|
||||||
if part != "0xd03" || processors != 2 {
|
|
||||||
t.Fatalf("part,processors = %q,%d, want 0xd03,2", part, processors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCompatibleSoC(t *testing.T) {
|
|
||||||
raw := "xunlong,orangepi-zero3\x00allwinner,sun50i-h618\x00"
|
|
||||||
if soc := parseCompatibleSoC(raw); soc != "Allwinner sun50i-h618" {
|
|
||||||
t.Fatalf("soc = %q", soc)
|
|
||||||
}
|
|
||||||
if soc := parseCompatibleSoC(""); soc != "" {
|
|
||||||
t.Fatalf("empty compatible must yield empty soc, got %q", soc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComposeARMCPUModel(t *testing.T) {
|
|
||||||
model := composeARMCPUModel("Allwinner sun50i-h618", "0xd03", 4)
|
|
||||||
if model != "Allwinner sun50i-h618 · 4× Cortex-A53" {
|
|
||||||
t.Fatalf("model = %q", model)
|
|
||||||
}
|
|
||||||
if model := composeARMCPUModel("", "", 0); model != "" {
|
|
||||||
t.Fatalf("empty inputs must yield empty model, got %q", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDmidecodeMemory(t *testing.T) {
|
|
||||||
output := `# dmidecode 3.3
|
|
||||||
Getting SMBIOS data from sysfs.
|
|
||||||
SMBIOS 3.0 present.
|
|
||||||
|
|
||||||
Handle 0x0010, DMI type 17, 40 bytes
|
|
||||||
Memory Device
|
|
||||||
Array Handle: 0x000F
|
|
||||||
Error Information Handle: Not Provided
|
|
||||||
Total Width: 64 bits
|
|
||||||
Data Width: 64 bits
|
|
||||||
Size: 8 GB
|
|
||||||
Form Factor: SODIMM
|
|
||||||
Type: DDR4
|
|
||||||
Speed: 2400 MT/s
|
|
||||||
Manufacturer: Samsung
|
|
||||||
Serial Number: 12345678
|
|
||||||
Part Number: M471A1K43CB1-CRC
|
|
||||||
Rank: 1
|
|
||||||
|
|
||||||
Handle 0x0011, DMI type 17, 40 bytes
|
|
||||||
Memory Device
|
|
||||||
Size: No Module Installed
|
|
||||||
Type: Unknown
|
|
||||||
`
|
|
||||||
if model := parseDmidecodeMemory(output); model != "8 GB DDR4 M471A1K43CB1-CRC" {
|
|
||||||
t.Fatalf("model = %q", model)
|
|
||||||
}
|
|
||||||
if model := parseDmidecodeMemory("Memory Device\n\tSize: No Module Installed\n"); model != "" {
|
|
||||||
t.Fatalf("unpopulated slots must yield empty model, got %q", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClampPercent(t *testing.T) {
|
|
||||||
if clampPercent(-1) != 0 || clampPercent(101) != 100 || clampPercent(50) != 50 {
|
|
||||||
t.Fatal("clampPercent bounds violated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseUintTrims(t *testing.T) {
|
|
||||||
if value, ok := parseUint(" 61440000 "); !ok || value != 61440000 {
|
|
||||||
t.Fatalf("parseUint = %d,%v", value, ok)
|
|
||||||
}
|
|
||||||
if _, ok := parseUint("not-a-number"); ok {
|
|
||||||
t.Fatal("parseUint accepted garbage")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkipHostDiskPrefixes(t *testing.T) {
|
|
||||||
skipped := []string{"loop0", "ram0", "zram0", "sr0", "nbd0", "dm-0", "md0", "mtdblock0", "ubiblock0_0"}
|
|
||||||
for _, name := range skipped {
|
|
||||||
if !skipHostDisk(name) {
|
|
||||||
t.Fatalf("%s should be skipped", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept := []string{"sda", "nvme0n1", "mmcblk0", "vda", "sdb"}
|
|
||||||
for _, name := range kept {
|
|
||||||
if skipHostDisk(name) {
|
|
||||||
t.Fatalf("%s should be kept", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxLarkPayloadBytes = 20 << 10
|
|
||||||
|
|
||||||
var larkTemplateVariableNames = []string{
|
|
||||||
"event",
|
|
||||||
"title",
|
|
||||||
"message",
|
|
||||||
"timestamp",
|
|
||||||
"content",
|
|
||||||
"number",
|
|
||||||
"device_id",
|
|
||||||
"device_name",
|
|
||||||
"device_label",
|
|
||||||
"time",
|
|
||||||
}
|
|
||||||
|
|
||||||
var larkTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^{}]*\}\}`)
|
|
||||||
|
|
||||||
var larkWebhookHosts = map[string]struct{}{
|
|
||||||
"open.feishu.cn": {},
|
|
||||||
"open.larksuite.com": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
type larkTemplateValues map[string]string
|
|
||||||
|
|
||||||
func renderLarkPayload(template string, values larkTemplateValues) ([]byte, error) {
|
|
||||||
encodedValues := make(map[string]string, len(larkTemplateVariableNames))
|
|
||||||
for _, name := range larkTemplateVariableNames {
|
|
||||||
encoded, err := json.Marshal(values[name])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Lark template value %q: %w", name, err)
|
|
||||||
}
|
|
||||||
encodedValues[name] = string(encoded)
|
|
||||||
}
|
|
||||||
unsupported := false
|
|
||||||
rendered := larkTemplatePlaceholderPattern.ReplaceAllStringFunc(template, func(placeholder string) string {
|
|
||||||
name := placeholder[2 : len(placeholder)-2]
|
|
||||||
encoded, ok := encodedValues[name]
|
|
||||||
if !ok {
|
|
||||||
unsupported = true
|
|
||||||
return placeholder
|
|
||||||
}
|
|
||||||
return encoded
|
|
||||||
})
|
|
||||||
remainder := larkTemplatePlaceholderPattern.ReplaceAllString(template, "")
|
|
||||||
if unsupported || strings.Contains(remainder, "{{") {
|
|
||||||
return nil, errors.New("lark.payload_template contains an unsupported variable")
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal([]byte(rendered), &payload); err != nil || len(payload) == 0 {
|
|
||||||
return nil, errors.New("lark.payload_template must render to a non-empty JSON object")
|
|
||||||
}
|
|
||||||
if len(rendered) > maxLarkPayloadBytes {
|
|
||||||
return nil, errors.New("lark.payload_template renders beyond the 20 KB Lark limit")
|
|
||||||
}
|
|
||||||
return []byte(rendered), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func larkSignature(timestamp int64, secret string) string {
|
|
||||||
key := strconv.FormatInt(timestamp, 10) + "\n" + secret
|
|
||||||
signature := hmac.New(sha256.New, []byte(key))
|
|
||||||
return base64.StdEncoding.EncodeToString(signature.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func signLarkPayload(payload []byte, secret string, now time.Time) ([]byte, error) {
|
|
||||||
if secret == "" {
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
var document map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(payload, &document); err != nil || len(document) == 0 {
|
|
||||||
return nil, errors.New("lark payload must be a non-empty JSON object")
|
|
||||||
}
|
|
||||||
timestamp := now.Unix()
|
|
||||||
document["timestamp"], _ = json.Marshal(strconv.FormatInt(timestamp, 10))
|
|
||||||
document["sign"], _ = json.Marshal(larkSignature(timestamp, secret))
|
|
||||||
signed, err := json.Marshal(document)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode signed Lark payload: %w", err)
|
|
||||||
}
|
|
||||||
if len(signed) > maxLarkPayloadBytes {
|
|
||||||
return nil, errors.New("lark payload exceeds the 20 KB Lark limit after signing")
|
|
||||||
}
|
|
||||||
return signed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateLarkResponse(status int, body []byte) error {
|
|
||||||
var result struct {
|
|
||||||
Code *int `json:"code"`
|
|
||||||
StatusCode *int `json:"StatusCode"`
|
|
||||||
}
|
|
||||||
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil {
|
|
||||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
|
||||||
}
|
|
||||||
if result.Code != nil {
|
|
||||||
if *result.Code == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
|
||||||
}
|
|
||||||
if result.StatusCode == nil || *result.StatusCode != 0 {
|
|
||||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
|
||||||
parsed, err := parseOutboundURL(raw, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok {
|
|
||||||
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
|
|
||||||
}
|
|
||||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
|
||||||
return nil, errors.New("Lark group bot webhook must use the default HTTPS port")
|
|
||||||
}
|
|
||||||
const prefix = "/open-apis/bot/v2/hook/"
|
|
||||||
token := strings.TrimPrefix(parsed.Path, prefix)
|
|
||||||
if token == parsed.Path || token == "" || strings.Contains(token, "/") || parsed.RawPath != "" ||
|
|
||||||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
|
||||||
return nil, errors.New("Lark group bot webhook path is invalid")
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
|
||||||
parsed, err := parseLarkWebhookURL(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func larkTestValues(now time.Time) larkTemplateValues {
|
|
||||||
return larkTemplateValues{
|
|
||||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
|
||||||
"timestamp": now.UTC().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func larkSMSValues(message smsNotification) larkTemplateValues {
|
|
||||||
return larkTemplateValues{
|
|
||||||
"event": "sms.received",
|
|
||||||
"title": "收到新短信",
|
|
||||||
"message": message.Text(),
|
|
||||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
|
||||||
"content": message.Content,
|
|
||||||
"number": message.Number,
|
|
||||||
"device_id": message.DeviceID,
|
|
||||||
"device_name": message.DeviceName,
|
|
||||||
"device_label": message.DeviceLabel,
|
|
||||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValues {
|
|
||||||
return larkTemplateValues{
|
|
||||||
"event": "automatic_task.completed",
|
|
||||||
"title": message.Title,
|
|
||||||
"message": message.Text,
|
|
||||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
|
||||||
"content": "",
|
|
||||||
"number": "",
|
|
||||||
"device_id": "",
|
|
||||||
"device_name": "",
|
|
||||||
"device_label": "",
|
|
||||||
"time": "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateLarkNotificationConfig(config map[string]any) error {
|
|
||||||
if configString(config, "url") == "" {
|
|
||||||
return errors.New("lark.url is required")
|
|
||||||
}
|
|
||||||
template := configString(config, "payload_template")
|
|
||||||
if template == "" {
|
|
||||||
return errors.New("lark.payload_template is required")
|
|
||||||
}
|
|
||||||
if signingEnabled, _ := config["signing_enabled"].(bool); signingEnabled {
|
|
||||||
secret := configString(config, "secret")
|
|
||||||
if secret == "" {
|
|
||||||
return errors.New("lark.secret is required when signing is enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func larkSigningSecret(config map[string]any) string {
|
|
||||||
enabled, _ := config["signing_enabled"].(bool)
|
|
||||||
if !enabled {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return configString(config, "secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
|
|
||||||
if err := validateLarkNotificationConfig(config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
payload, err = signLarkPayload(payload, larkSigningSecret(config), time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parsed, err := validateLarkWebhookURL(ctx, configString(config, "url"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return postLarkNotification(ctx, client, parsed.String(), payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postLarkNotification(ctx context.Context, client *http.Client, endpoint string, payload []byte) error {
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create Lark notification request: %w", err)
|
|
||||||
}
|
|
||||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
request.Header.Set("User-Agent", "vocat-lark-notification/1")
|
|
||||||
response, err := client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
|
|
||||||
}
|
|
||||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
|
||||||
closeErr := response.Body.Close()
|
|
||||||
if readErr != nil {
|
|
||||||
return fmt.Errorf("read Lark response: %w", readErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
return fmt.Errorf("close Lark response: %w", closeErr)
|
|
||||||
}
|
|
||||||
if err := validateLarkResponse(response.StatusCode, body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeLarkRequestError(err error) error {
|
|
||||||
var requestErr *url.Error
|
|
||||||
if errors.As(err, &requestErr) && requestErr.Err != nil {
|
|
||||||
return requestErr.Err
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendLarkNotificationTest(ctx context.Context, config map[string]any) error {
|
|
||||||
return sendLarkNotification(ctx, config, larkTestValues(time.Now()))
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRenderLarkPayloadEscapesTemplateValues(t *testing.T) {
|
|
||||||
payload, err := renderLarkPayload(
|
|
||||||
`{"msg_type":"text","content":{"text":{{message}},"number":{{number}}}}`,
|
|
||||||
larkTemplateValues{
|
|
||||||
"message": "quote: \"\nline",
|
|
||||||
"number": "+447386",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, want := string(payload), `{"msg_type":"text","content":{"text":"quote: \"\nline","number":"+447386"}}`; got != want {
|
|
||||||
t.Fatalf("payload = %s, want %s", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderLarkPayloadDoesNotInterpretPlaceholdersInsideValues(t *testing.T) {
|
|
||||||
payload, err := renderLarkPayload(
|
|
||||||
`{"msg_type":"text","content":{"text":{{message}}}}`,
|
|
||||||
larkTemplateValues{"message": "keep {{timestamp}} literally", "timestamp": "changed"},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, want := string(payload), `{"msg_type":"text","content":{"text":"keep {{timestamp}} literally"}}`; got != want {
|
|
||||||
t.Fatalf("payload = %s, want %s", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderLarkPayloadRejectsInvalidTemplate(t *testing.T) {
|
|
||||||
for _, template := range []string{
|
|
||||||
`{"text":{{unknown}}}`,
|
|
||||||
`[]`,
|
|
||||||
`{"msg_type":"text"`,
|
|
||||||
`{"text":"` + strings.Repeat("x", maxLarkPayloadBytes) + `"}`,
|
|
||||||
} {
|
|
||||||
t.Run(template[:min(len(template), 40)], func(t *testing.T) {
|
|
||||||
if _, err := renderLarkPayload(template, larkTemplateValues{}); err == nil {
|
|
||||||
t.Fatalf("template was accepted")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignLarkPayload(t *testing.T) {
|
|
||||||
const timestamp = int64(1_599_360_473)
|
|
||||||
if got, want := larkSignature(timestamp, "demo"), "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8="; got != want {
|
|
||||||
t.Fatalf("signature = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
unsigned := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
|
||||||
signed, err := signLarkPayload(unsigned, "demo", time.Unix(timestamp, 0))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(signed, &payload); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if payload["timestamp"] != "1599360473" || payload["sign"] != "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8=" {
|
|
||||||
t.Fatalf("signed payload = %#v", payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
untouched, err := signLarkPayload(unsigned, "", time.Unix(timestamp, 0))
|
|
||||||
if err != nil || string(untouched) != string(unsigned) {
|
|
||||||
t.Fatalf("unsigned payload = %s, err = %v", untouched, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateLarkResponse(t *testing.T) {
|
|
||||||
for _, body := range []string{
|
|
||||||
`{"code":0,"msg":"success"}`,
|
|
||||||
`{"StatusCode":0,"StatusMessage":"success"}`,
|
|
||||||
} {
|
|
||||||
if err := validateLarkResponse(http.StatusOK, []byte(body)); err != nil {
|
|
||||||
t.Fatalf("successful response %s = %v", body, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, response := range []struct {
|
|
||||||
status int
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{http.StatusBadGateway, `{"code":0}`},
|
|
||||||
{http.StatusOK, `{"code":19021,"msg":"sign match fail or timestamp is not within one hour from current time","StatusCode":0}`},
|
|
||||||
{http.StatusOK, `{"StatusCode":19021,"StatusMessage":"sign error"}`},
|
|
||||||
{http.StatusOK, `{}`},
|
|
||||||
{http.StatusOK, `not-json`},
|
|
||||||
} {
|
|
||||||
if err := validateLarkResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
|
|
||||||
t.Fatalf("validateLarkResponse(%d, %s) = %v", response.status, response.body, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostLarkNotificationSendsJSONPayload(t *testing.T) {
|
|
||||||
payload := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
|
||||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
t.Errorf("method = %q, want POST", r.Method)
|
|
||||||
}
|
|
||||||
if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf-8" {
|
|
||||||
t.Errorf("Content-Type = %q", got)
|
|
||||||
}
|
|
||||||
if got := r.Header.Get("User-Agent"); got != "vocat-lark-notification/1" {
|
|
||||||
t.Errorf("User-Agent = %q", got)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("read body: %v", err)
|
|
||||||
}
|
|
||||||
if string(body) != string(payload) {
|
|
||||||
t.Errorf("body = %s, want %s", body, payload)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
|
|
||||||
}))
|
|
||||||
t.Cleanup(provider.Close)
|
|
||||||
|
|
||||||
if err := postLarkNotification(context.Background(), provider.Client(), provider.URL, payload); err != nil {
|
|
||||||
t.Fatalf("postLarkNotification() = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseLarkWebhookURL(t *testing.T) {
|
|
||||||
for _, raw := range []string{
|
|
||||||
"https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token",
|
|
||||||
"https://open.larksuite.com/open-apis/bot/v2/hook/lark-token",
|
|
||||||
"https://open.larksuite.com:443/open-apis/bot/v2/hook/lark-token",
|
|
||||||
} {
|
|
||||||
if _, err := parseLarkWebhookURL(raw); err != nil {
|
|
||||||
t.Errorf("parseLarkWebhookURL(%q) = %v", raw, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, raw := range []string{
|
|
||||||
"http://open.larksuite.com/open-apis/bot/v2/hook/token",
|
|
||||||
"https://example.com/open-apis/bot/v2/hook/token",
|
|
||||||
"https://open.larksuite.com/open-apis/bot/hook/token",
|
|
||||||
"https://open.larksuite.com/open-apis/bot/v2/hook/",
|
|
||||||
"https://open.larksuite.com/open-apis/bot/v2/hook/token/extra",
|
|
||||||
"https://open.larksuite.com/open-apis/bot/v2/hook/token?query=1",
|
|
||||||
} {
|
|
||||||
if _, err := parseLarkWebhookURL(raw); err == nil {
|
|
||||||
t.Errorf("parseLarkWebhookURL(%q) accepted an invalid group bot webhook", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateLarkNotificationConfig(t *testing.T) {
|
|
||||||
valid := map[string]any{
|
|
||||||
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
|
||||||
"signing_enabled": true,
|
|
||||||
"secret": "demo",
|
|
||||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
|
||||||
}
|
|
||||||
if err := validateLarkNotificationConfig(valid); err != nil {
|
|
||||||
t.Fatalf("valid config = %v", err)
|
|
||||||
}
|
|
||||||
unsigned := map[string]any{
|
|
||||||
"url": valid["url"],
|
|
||||||
"signing_enabled": false,
|
|
||||||
"payload_template": valid["payload_template"],
|
|
||||||
}
|
|
||||||
if err := validateLarkNotificationConfig(unsigned); err != nil {
|
|
||||||
t.Fatalf("unsigned config = %v", err)
|
|
||||||
}
|
|
||||||
if secret := larkSigningSecret(map[string]any{"signing_enabled": false, "secret": "demo"}); secret != "" {
|
|
||||||
t.Fatalf("disabled signing secret = %q", secret)
|
|
||||||
}
|
|
||||||
if secret := larkSigningSecret(valid); secret != "demo" {
|
|
||||||
t.Fatalf("enabled signing secret = %q", secret)
|
|
||||||
}
|
|
||||||
for _, config := range []map[string]any{
|
|
||||||
{"payload_template": valid["payload_template"]},
|
|
||||||
{"url": valid["url"]},
|
|
||||||
{"url": valid["url"], "signing_enabled": true, "payload_template": valid["payload_template"]},
|
|
||||||
} {
|
|
||||||
if err := validateLarkNotificationConfig(config); err == nil {
|
|
||||||
t.Fatalf("invalid config was accepted: %#v", config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeLarkRequestErrorRemovesWebhookURL(t *testing.T) {
|
|
||||||
const webhookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/sensitive-token"
|
|
||||||
err := sanitizeLarkRequestError(&url.Error{Op: "Post", URL: webhookURL, Err: errors.New("dial failed")})
|
|
||||||
if strings.Contains(err.Error(), "sensitive-token") || err.Error() != "dial failed" {
|
|
||||||
t.Fatalf("sanitized error = %q", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/device"
|
|
||||||
"vocat/internal/i18n"
|
"vocat/internal/i18n"
|
||||||
localproxy "vocat/internal/proxy"
|
localproxy "vocat/internal/proxy"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
@@ -102,48 +100,6 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
|||||||
}
|
}
|
||||||
payload.ID = id
|
payload.ID = id
|
||||||
s.saveAndProbeUpstream(w, r, payload)
|
s.saveAndProbeUpstream(w, r, payload)
|
||||||
case http.MethodPatch:
|
|
||||||
var request struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
value, err := s.store.UpstreamProxy(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
s.writeStoreError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
value.Enabled = request.Enabled
|
|
||||||
value.UpdatedAt = time.Now().UTC()
|
|
||||||
if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
|
|
||||||
s.writeStoreError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bindings, err := s.store.ListDeviceProxyBindings(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
s.writeStoreError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reconnectRequested := false
|
|
||||||
var reconnectErrors []string
|
|
||||||
for _, binding := range bindings {
|
|
||||||
if binding.UpstreamProxyID != id {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
|
||||||
reconnectRequested = reconnectRequested || requested
|
|
||||||
if reconnectErr != nil {
|
|
||||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response := upstreamProxyResponse(value.Redacted())
|
|
||||||
response["reconnect_requested"] = reconnectRequested
|
|
||||||
if len(reconnectErrors) > 0 {
|
|
||||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
@@ -161,7 +117,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "PUT, PATCH, DELETE")
|
w.Header().Set("Allow", "PUT, DELETE")
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,8 +351,8 @@ func (s *Server) saveAndProbeUpstream(
|
|||||||
)
|
)
|
||||||
probeResponse := probeMap(probe, probeErr)
|
probeResponse := probeMap(probe, probeErr)
|
||||||
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
||||||
if probeErr == nil && probe.UDPExchangeOK {
|
if probeErr == nil && probe.UDPAssociateOK {
|
||||||
message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。")
|
message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -425,8 +381,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id
|
|||||||
8*time.Second,
|
8*time.Second,
|
||||||
)
|
)
|
||||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||||
if probeErr == nil && result.UDPExchangeOK {
|
if probeErr == nil && result.UDPAssociateOK {
|
||||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -479,8 +435,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques
|
|||||||
8*time.Second,
|
8*time.Second,
|
||||||
)
|
)
|
||||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||||
if probeErr == nil && result.UDPExchangeOK {
|
if probeErr == nil && result.UDPAssociateOK {
|
||||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -633,7 +589,7 @@ func countryNameForMCC(mcc string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var namedProxyCountries = []proxyCountry{
|
var proxyCountries = []proxyCountry{
|
||||||
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
||||||
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
||||||
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
||||||
@@ -688,26 +644,3 @@ var namedProxyCountries = []proxyCountry{
|
|||||||
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
||||||
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
|
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var proxyCountries = buildProxyCountries()
|
|
||||||
|
|
||||||
func buildProxyCountries() []proxyCountry {
|
|
||||||
byCode := make(map[string]proxyCountry)
|
|
||||||
for _, country := range namedProxyCountries {
|
|
||||||
byCode[country.Code] = country
|
|
||||||
}
|
|
||||||
for code, mccs := range device.MCCsByCountry() {
|
|
||||||
country, found := byCode[code]
|
|
||||||
if !found {
|
|
||||||
country = proxyCountry{Code: code, Name: code}
|
|
||||||
}
|
|
||||||
country.MCCs = append([]string(nil), mccs...)
|
|
||||||
byCode[code] = country
|
|
||||||
}
|
|
||||||
result := make([]proxyCountry, 0, len(byCode))
|
|
||||||
for _, country := range byCode {
|
|
||||||
result = append(result, country)
|
|
||||||
}
|
|
||||||
sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code })
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ type Server struct {
|
|||||||
updateApplying bool
|
updateApplying bool
|
||||||
https *httpsmode.Manager
|
https *httpsmode.Manager
|
||||||
netTraffic *liveNetTracker
|
netTraffic *liveNetTracker
|
||||||
hostStats *hostStatsSampler
|
|
||||||
publicIPMu sync.RWMutex
|
publicIPMu sync.RWMutex
|
||||||
publicIPs map[string]cachedPublicIP
|
publicIPs map[string]cachedPublicIP
|
||||||
automaticTasks *automaticTaskScheduler
|
automaticTasks *automaticTaskScheduler
|
||||||
@@ -134,7 +133,6 @@ func New(options Options) (*Server, error) {
|
|||||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||||
https: options.HTTPS,
|
https: options.HTTPS,
|
||||||
netTraffic: newLiveNetTracker(),
|
netTraffic: newLiveNetTracker(),
|
||||||
hostStats: newHostStatsSampler(),
|
|
||||||
publicIPs: make(map[string]cachedPublicIP),
|
publicIPs: make(map[string]cachedPublicIP),
|
||||||
updateCheck: update.CheckLatest,
|
updateCheck: update.CheckLatest,
|
||||||
updateApply: update.ApplyLatest,
|
updateApply: update.ApplyLatest,
|
||||||
@@ -144,9 +142,6 @@ func New(options Options) (*Server, error) {
|
|||||||
server.loadUILanguage(context.Background())
|
server.loadUILanguage(context.Background())
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", server.handleLiveness)
|
|
||||||
mux.HandleFunc("/readyz", server.handleReadiness)
|
|
||||||
mux.HandleFunc("/metrics", server.handleMetrics)
|
|
||||||
mux.HandleFunc("/api/health", server.handleHealth)
|
mux.HandleFunc("/api/health", server.handleHealth)
|
||||||
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
||||||
mux.HandleFunc("/api/auth/session", server.handleSession)
|
mux.HandleFunc("/api/auth/session", server.handleSession)
|
||||||
@@ -172,11 +167,6 @@ type VoWiFiController interface {
|
|||||||
RequestReconnect(string) (vowifi.State, error)
|
RequestReconnect(string) (vowifi.State, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type VoWiFiMaintenanceController interface {
|
|
||||||
BeginMaintenance(string) error
|
|
||||||
EndMaintenance(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
type VoWiFiCallController interface {
|
type VoWiFiCallController interface {
|
||||||
Calls(string) ([]vowifi.Call, error)
|
Calls(string) ([]vowifi.Call, error)
|
||||||
DialCall(context.Context, string, string) (vowifi.Call, error)
|
DialCall(context.Context, string, string) (vowifi.Call, error)
|
||||||
|
|||||||
+50
-127
@@ -29,7 +29,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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")
|
errProviderRejected = errors.New("notification provider rejected the test")
|
||||||
telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
|
telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
|
||||||
)
|
)
|
||||||
@@ -41,7 +41,6 @@ var notificationChannels = []string{
|
|||||||
"bark",
|
"bark",
|
||||||
"pushplus",
|
"pushplus",
|
||||||
"wecom",
|
"wecom",
|
||||||
"lark",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var notificationFields = map[string]map[string]string{
|
var notificationFields = map[string]map[string]string{
|
||||||
@@ -66,9 +65,6 @@ var notificationFields = map[string]map[string]string{
|
|||||||
"wecom": {
|
"wecom": {
|
||||||
"urls": "strings", "payload_template": "string",
|
"urls": "strings", "payload_template": "string",
|
||||||
},
|
},
|
||||||
"lark": {
|
|
||||||
"url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||||
@@ -244,20 +240,6 @@ func decodeNotificationConfig(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
||||||
}
|
}
|
||||||
if enabled && channel == "lark" {
|
|
||||||
var resolved map[string]any
|
|
||||||
if err := json.Unmarshal(config, &resolved); err != nil {
|
|
||||||
return false, nil, fmt.Errorf("decode lark notification config: %w", err)
|
|
||||||
}
|
|
||||||
signingEnabled, _ := resolved["signing_enabled"].(bool)
|
|
||||||
if signingEnabled && configString(resolved, "url") != store.SecretMask &&
|
|
||||||
configString(resolved, "secret") == store.SecretMask {
|
|
||||||
return false, nil, errors.New("lark.secret must be re-entered when lark.url changes")
|
|
||||||
}
|
|
||||||
if err := validateLarkNotificationConfig(resolved); err != nil {
|
|
||||||
return false, nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return enabled, config, nil
|
return enabled, config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,9 +265,6 @@ func validateNotificationField(
|
|||||||
if name == "text_template" {
|
if name == "text_template" {
|
||||||
limit = 32768
|
limit = 32768
|
||||||
}
|
}
|
||||||
if channel == "lark" && name == "payload_template" {
|
|
||||||
limit = maxLarkPayloadBytes
|
|
||||||
}
|
|
||||||
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
||||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
||||||
}
|
}
|
||||||
@@ -321,16 +300,6 @@ func validateNotificationField(
|
|||||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if channel == "lark" && name == "payload_template" && value != "" {
|
|
||||||
if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil {
|
|
||||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if channel == "lark" && name == "url" && value != "" && value != store.SecretMask {
|
|
||||||
if _, err := parseLarkWebhookURL(value); err != nil {
|
|
||||||
return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "integer":
|
case "integer":
|
||||||
var value int
|
var value int
|
||||||
if err := json.Unmarshal(raw, &value); err != nil {
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
@@ -418,7 +387,7 @@ func (s *Server) handleNotificationTest(
|
|||||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" {
|
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
||||||
writeError(
|
writeError(
|
||||||
w,
|
w,
|
||||||
http.StatusNotImplemented,
|
http.StatusNotImplemented,
|
||||||
@@ -472,8 +441,6 @@ func (s *Server) handleNotificationTest(
|
|||||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||||
case "lark":
|
|
||||||
err = sendLarkNotificationTest(notificationContext, resolved)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redacted := store.RedactText(err.Error(), provider)
|
redacted := store.RedactText(err.Error(), provider)
|
||||||
@@ -492,7 +459,7 @@ func (s *Server) handleNotificationTest(
|
|||||||
w,
|
w,
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
"unsafe_destination",
|
"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):
|
case errors.Is(err, errProviderRejected):
|
||||||
writeError(
|
writeError(
|
||||||
@@ -579,8 +546,8 @@ func (s *Server) resolveNotificationTestConfig(
|
|||||||
|
|
||||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||||
// settings form while allowing newly entered sensitive values in the same
|
// settings form while allowing newly entered sensitive values in the same
|
||||||
// request. Provider webhook URLs can be sensitive lists, unlike the
|
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
||||||
// string-based secrets used by the other notification channels.
|
// used by the other notification channels.
|
||||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
return existing
|
return existing
|
||||||
@@ -628,8 +595,6 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
|||||||
}
|
}
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return validateWecomNotificationConfig(config)
|
return validateWecomNotificationConfig(config)
|
||||||
case "lark":
|
|
||||||
return validateLarkNotificationConfig(config)
|
|
||||||
case "telegram":
|
case "telegram":
|
||||||
token := configString(config, "bot_token")
|
token := configString(config, "bot_token")
|
||||||
if token == "" || token == store.SecretMask {
|
if token == "" || token == store.SecretMask {
|
||||||
@@ -940,12 +905,11 @@ func restrictedHTTPClient(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(proxy) != "" {
|
if strings.TrimSpace(proxy) != "" {
|
||||||
parsed, err := validateNotificationProxyURL(ctx, proxy)
|
parsed, err := validateOutboundURL(ctx, proxy, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("validate notification proxy: %w", err)
|
return nil, fmt.Errorf("validate notification proxy: %w", err)
|
||||||
}
|
}
|
||||||
transport.Proxy = http.ProxyURL(parsed)
|
transport.Proxy = http.ProxyURL(parsed)
|
||||||
transport.DialContext = notificationProxyDialer(timeout)
|
|
||||||
}
|
}
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
@@ -988,17 +952,6 @@ func validateOutboundURL(
|
|||||||
return parsed, nil
|
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) {
|
func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||||
@@ -1032,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(
|
func dialRestricted(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
network string,
|
network string,
|
||||||
address string,
|
address string,
|
||||||
timeout time.Duration,
|
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) {
|
) (net.Conn, error) {
|
||||||
host, port, err := net.SplitHostPort(address)
|
host, port, err := net.SplitHostPort(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse outbound address: %w", err)
|
return nil, fmt.Errorf("parse outbound address: %w", err)
|
||||||
}
|
}
|
||||||
var addresses []netip.Addr
|
addresses, err := resolvePublicAddresses(ctx, host)
|
||||||
if allowLocal {
|
|
||||||
addresses, err = resolveNotificationProxyAddresses(ctx, host)
|
|
||||||
} else {
|
|
||||||
addresses, err = resolvePublicAddresses(ctx, host)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1143,68 +1071,54 @@ func dialNotification(
|
|||||||
if len(failures) == 0 {
|
if len(failures) == 0 {
|
||||||
return nil, ctx.Err()
|
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 {
|
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
// Notification delivery is outbound administrator-configured traffic. It
|
access := s.currentAccessConfig()
|
||||||
// must not inherit the inbound Web access policy: DNS Fake-IP ranges, LAN
|
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
|
||||||
// gateways, and local proxies are valid notification paths.
|
|
||||||
return ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func notificationAddressAllowed(_ context.Context, address netip.Addr) bool {
|
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
|
||||||
address = address.Unmap()
|
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
|
return false
|
||||||
}
|
}
|
||||||
for _, fakeIP := range notificationFakeIPNetworks {
|
if publicNotificationAddress(address) {
|
||||||
if fakeIP.Contains(address) {
|
return true
|
||||||
|
}
|
||||||
|
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if prefix.Contains(address) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !address.IsGlobalUnicast() {
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, blocked := range blockedNotificationDestinationNetworks {
|
|
||||||
if blocked.Contains(address) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func notificationProxyAddressAllowed(address netip.Addr) bool {
|
|
||||||
return notificationTransportAddress(address.Unmap())
|
|
||||||
}
|
|
||||||
|
|
||||||
func notificationTransportAddress(address netip.Addr) bool {
|
|
||||||
return address.IsValid() && !address.IsUnspecified() && !address.IsMulticast() &&
|
|
||||||
!address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() &&
|
|
||||||
address != netip.MustParseAddr("255.255.255.255") &&
|
|
||||||
address != netip.MustParseAddr("100.100.100.200")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
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), "."))
|
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)
|
return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
|
||||||
}
|
}
|
||||||
if literal, err := netip.ParseAddr(normalized); err == nil {
|
if literal, err := netip.ParseAddr(normalized); err == nil {
|
||||||
literal = literal.Unmap()
|
literal = literal.Unmap()
|
||||||
if (!allowLocal && !notificationAddressAllowed(ctx, literal)) ||
|
if !notificationAddressAllowed(ctx, literal) {
|
||||||
(allowLocal && !notificationProxyAddressAllowed(literal)) {
|
|
||||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
||||||
}
|
}
|
||||||
return []netip.Addr{literal}, nil
|
return []netip.Addr{literal}, nil
|
||||||
@@ -1219,8 +1133,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
|
|||||||
result := make([]netip.Addr, 0, len(addresses))
|
result := make([]netip.Addr, 0, len(addresses))
|
||||||
for _, address := range addresses {
|
for _, address := range addresses {
|
||||||
address = address.Unmap()
|
address = address.Unmap()
|
||||||
if (!allowLocal && !notificationAddressAllowed(ctx, address)) ||
|
if !notificationAddressAllowed(ctx, address) {
|
||||||
(allowLocal && !notificationProxyAddressAllowed(address)) {
|
|
||||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
||||||
}
|
}
|
||||||
result = append(result, address)
|
result = append(result, address)
|
||||||
@@ -1228,11 +1141,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var notificationFakeIPNetworks = []netip.Prefix{
|
var blockedNotificationNetworks = []netip.Prefix{
|
||||||
netip.MustParsePrefix("198.18.0.0/15"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var blockedNotificationDestinationNetworks = []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("0.0.0.0/8"),
|
netip.MustParsePrefix("0.0.0.0/8"),
|
||||||
netip.MustParsePrefix("10.0.0.0/8"),
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
netip.MustParsePrefix("100.64.0.0/10"),
|
netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
@@ -1243,6 +1152,7 @@ var blockedNotificationDestinationNetworks = []netip.Prefix{
|
|||||||
netip.MustParsePrefix("192.0.2.0/24"),
|
netip.MustParsePrefix("192.0.2.0/24"),
|
||||||
netip.MustParsePrefix("192.88.99.0/24"),
|
netip.MustParsePrefix("192.88.99.0/24"),
|
||||||
netip.MustParsePrefix("192.168.0.0/16"),
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
netip.MustParsePrefix("198.18.0.0/15"),
|
||||||
netip.MustParsePrefix("198.51.100.0/24"),
|
netip.MustParsePrefix("198.51.100.0/24"),
|
||||||
netip.MustParsePrefix("203.0.113.0/24"),
|
netip.MustParsePrefix("203.0.113.0/24"),
|
||||||
netip.MustParsePrefix("224.0.0.0/4"),
|
netip.MustParsePrefix("224.0.0.0/4"),
|
||||||
@@ -1257,6 +1167,19 @@ var blockedNotificationDestinationNetworks = []netip.Prefix{
|
|||||||
netip.MustParsePrefix("ff00::/8"),
|
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 {
|
func configString(config map[string]any, key string) string {
|
||||||
value, _ := config[key].(string)
|
value, _ := config[key].(string)
|
||||||
return strings.TrimSpace(value)
|
return strings.TrimSpace(value)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) {
|
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
@@ -179,73 +179,6 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) {
|
|
||||||
test := newSettingsAPITest(t)
|
|
||||||
webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
|
||||||
secret := "lark-signing-secret"
|
|
||||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
|
||||||
first, err := json.Marshal(map[string]any{
|
|
||||||
"lark": map[string]any{
|
|
||||||
"enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "payload_template": template,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
|
||||||
if recorder.Code != http.StatusOK {
|
|
||||||
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
|
||||||
}
|
|
||||||
if bytes.Contains(recorder.Body.Bytes(), []byte("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) {
|
|
||||||
t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body)
|
|
||||||
}
|
|
||||||
response := decodeSettingsResponse(t, recorder)
|
|
||||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
|
||||||
if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask {
|
|
||||||
t.Fatalf("redacted Lark config = %#v", lark)
|
|
||||||
}
|
|
||||||
|
|
||||||
second, err := json.Marshal(map[string]any{
|
|
||||||
"lark": map[string]any{
|
|
||||||
"enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": store.SecretMask, "payload_template": template,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
|
||||||
if recorder.Code != http.StatusOK {
|
|
||||||
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
|
||||||
}
|
|
||||||
stored, err := test.database.NotificationSetting(context.Background(), "lark")
|
|
||||||
if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) {
|
|
||||||
t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) {
|
|
||||||
test := newSettingsAPITest(t)
|
|
||||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
|
||||||
body, err := json.Marshal(map[string]any{
|
|
||||||
"lark": map[string]any{
|
|
||||||
"enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
|
||||||
"signing_enabled": false, "payload_template": template,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body))
|
|
||||||
if recorder.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
|
||||||
}
|
|
||||||
response := decodeSettingsResponse(t, recorder)
|
|
||||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
|
||||||
if _, exists := lark["secret"]; exists {
|
|
||||||
t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
raw, err := json.Marshal(map[string]any{
|
raw, err := json.Marshal(map[string]any{
|
||||||
@@ -299,45 +232,6 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) {
|
|
||||||
test := newSettingsAPITest(t)
|
|
||||||
storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored"
|
|
||||||
storedConfig, err := json.Marshal(map[string]any{
|
|
||||||
"url": storedURL,
|
|
||||||
"signing_enabled": true,
|
|
||||||
"secret": "stored-signing-secret",
|
|
||||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
|
||||||
Channel: "lark",
|
|
||||||
Config: storedConfig,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(map[string]any{
|
|
||||||
"url": store.SecretMask,
|
|
||||||
"signing_enabled": true,
|
|
||||||
"secret": store.SecretMask,
|
|
||||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if resolved["url"] != storedURL {
|
|
||||||
t.Fatalf("resolved URL = %#v", resolved["url"])
|
|
||||||
}
|
|
||||||
if resolved["secret"] != "stored-signing-secret" {
|
|
||||||
t.Fatalf("resolved secret = %#v", resolved["secret"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -390,36 +284,6 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
|||||||
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
||||||
code: "invalid_notification_config",
|
code: "invalid_notification_config",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "invalid Lark payload template",
|
|
||||||
body: `{"lark":{"enabled":true,"payload_template":"[]"}}`,
|
|
||||||
code: "invalid_notification_config",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "enabled Lark config without webhook URL",
|
|
||||||
body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
|
||||||
code: "invalid_notification_config",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "enabled Lark signing without secret",
|
|
||||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
|
||||||
code: "invalid_notification_config",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "changed Lark URL with masked signing secret",
|
|
||||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`,
|
|
||||||
code: "invalid_notification_config",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "insecure Lark group bot URL",
|
|
||||||
body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`,
|
|
||||||
code: "invalid_notification_config",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-Lark group bot URL",
|
|
||||||
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
|
||||||
code: "invalid_notification_config",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "null body",
|
name: "null body",
|
||||||
body: `null`,
|
body: `null`,
|
||||||
@@ -505,10 +369,6 @@ func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusBadRequest {
|
if recorder.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body)
|
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(
|
recorder = test.request(
|
||||||
t,
|
t,
|
||||||
@@ -902,28 +762,26 @@ func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testing.T) {
|
func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||||
blocked := []string{
|
blocked := []string{
|
||||||
"0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
|
"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",
|
"169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1",
|
||||||
"255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::1",
|
"::1", "fc00::1", "fe80::1", "2001:db8::1",
|
||||||
}
|
}
|
||||||
for _, text := range blocked {
|
for _, text := range blocked {
|
||||||
address := netip.MustParseAddr(text)
|
address := netip.MustParseAddr(text)
|
||||||
if notificationAddressAllowed(context.Background(), address) {
|
if publicNotificationAddress(address) {
|
||||||
t.Errorf("%s was incorrectly accepted for notification transport", text)
|
t.Errorf("%s was incorrectly accepted as public", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, text := range []string{
|
for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
|
||||||
"1.1.1.1", "198.18.0.1", "2606:4700:4700::1111",
|
|
||||||
} {
|
|
||||||
address := netip.MustParseAddr(text)
|
address := netip.MustParseAddr(text)
|
||||||
if !notificationAddressAllowed(context.Background(), address) {
|
if !publicNotificationAddress(address) {
|
||||||
t.Errorf("%s was incorrectly blocked for notification transport", text)
|
t.Errorf("%s was incorrectly blocked", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
|
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(
|
if _, err := resolvePublicAddresses(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -931,53 +789,27 @@ func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testi
|
|||||||
); err == nil {
|
); err == nil {
|
||||||
t.Fatal("metadata IP was not blocked")
|
t.Fatal("metadata IP was not blocked")
|
||||||
}
|
}
|
||||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
allowedContext := context.WithValue(
|
||||||
notificationContext := server.notificationDestinationContext(context.Background())
|
context.Background(),
|
||||||
if addresses, err := resolvePublicAddresses(notificationContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
notificationAllowedNetworksKey{},
|
||||||
t.Fatalf("Fake-IP notification destination = %v, %v", addresses, err)
|
[]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)
|
||||||
}
|
}
|
||||||
}
|
if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
|
||||||
|
t.Fatal("unlisted metadata IP was allowed")
|
||||||
func TestNotificationProxyAcceptsLocalAddressWithoutWebAccessAllowlist(t *testing.T) {
|
}
|
||||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
wideAllowedContext := context.WithValue(
|
||||||
ctx := server.notificationDestinationContext(context.Background())
|
context.Background(),
|
||||||
for _, host := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "198.18.0.1", "::1"} {
|
notificationAllowedNetworksKey{},
|
||||||
if addresses, err := resolveNotificationProxyAddresses(ctx, host); err != nil || len(addresses) != 1 {
|
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
|
||||||
t.Errorf("local notification proxy %s = %v, %v", host, addresses, err)
|
)
|
||||||
|
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) {
|
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||||
|
|||||||
@@ -236,10 +236,6 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if store.NormalizeDeviceType(config.DeviceType) == store.DeviceTypeWiFi410 {
|
|
||||||
writeError(w, http.StatusNotImplemented, "device_feature_unsupported", "SMS is not supported by the native OpenStick 410 backend")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entry, physicalID, present := s.physicalForConfig(config)
|
entry, physicalID, present := s.physicalForConfig(config)
|
||||||
if !s.requirePhysicalDevice(w, present) {
|
if !s.requirePhysicalDevice(w, present) {
|
||||||
return
|
return
|
||||||
@@ -671,8 +667,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func supportsModemSMSStorage(config store.Device) bool {
|
func supportsModemSMSStorage(config store.Device) bool {
|
||||||
deviceType := store.NormalizeDeviceType(config.DeviceType)
|
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
|
||||||
return deviceType != store.DeviceTypeUSBSIMReader && deviceType != store.DeviceTypeWiFi410
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
|
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
|
||||||
|
|||||||
@@ -57,15 +57,6 @@ func TestSMSThreadAllDevicesUsesIMSIFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNative410DoesNotUseModemSMSStorage(t *testing.T) {
|
|
||||||
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeWiFi410}) {
|
|
||||||
t.Fatal("native OpenStick 410 unexpectedly enabled modem SMS storage polling")
|
|
||||||
}
|
|
||||||
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
|
|
||||||
t.Fatal("EC20 modem SMS storage polling was disabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSMSThreadConfiguredDeviceUsesStableIMEI(t *testing.T) {
|
func TestSMSThreadConfiguredDeviceUsesStableIMEI(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database, err := store.Open(ctx, ":memory:")
|
database, err := store.Open(ctx, ":memory:")
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
|
|
||||||
const smsNotificationPollInterval = 2 * time.Second
|
const smsNotificationPollInterval = 2 * time.Second
|
||||||
|
|
||||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"}
|
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||||
|
|
||||||
type smsNotification struct {
|
type smsNotification struct {
|
||||||
DeviceID string
|
DeviceID string
|
||||||
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
|||||||
|
|
||||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||||
switch channel {
|
switch channel {
|
||||||
case "bark", "email", "webhook", "wecom", "lark":
|
case "bark", "email", "webhook", "wecom":
|
||||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -204,8 +204,6 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
|||||||
return sendWebhookSMSNotification(ctx, config, message)
|
return sendWebhookSMSNotification(ctx, config, message)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||||
case "lark":
|
|
||||||
return sendLarkNotification(ctx, config, larkSMSValues(message))
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,31 +66,6 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) {
|
|
||||||
message := smsNotification{
|
|
||||||
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
|
||||||
Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello",
|
|
||||||
}
|
|
||||||
smsValues := larkSMSValues(message)
|
|
||||||
if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" ||
|
|
||||||
smsValues["message"] != message.Text() || smsValues["content"] != "hello" ||
|
|
||||||
smsValues["device_label"] != "EC20" {
|
|
||||||
t.Fatalf("Lark SMS values = %#v", smsValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
taskValues := larkAutomaticTaskValues(automaticTaskNotification{
|
|
||||||
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
|
||||||
})
|
|
||||||
if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" {
|
|
||||||
t.Fatalf("Lark automatic task values = %#v", taskValues)
|
|
||||||
}
|
|
||||||
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
|
||||||
if taskValues[name] != "" {
|
|
||||||
t.Fatalf("%s = %q, want empty", name, taskValues[name])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||||
valid := map[string]map[string]any{
|
valid := map[string]map[string]any{
|
||||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||||
@@ -101,12 +76,6 @@ func TestValidateSMSNotificationConfig(t *testing.T) {
|
|||||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||||
},
|
},
|
||||||
"lark": {
|
|
||||||
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret",
|
|
||||||
"signing_enabled": true,
|
|
||||||
"secret": "signing-secret",
|
|
||||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for channel, config := range valid {
|
for channel, config := range valid {
|
||||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -57,8 +58,10 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTelegramPollingAcceptsFakeIPWithoutWebAccessAllowlist(t *testing.T) {
|
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
|
||||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{mode: "internal"}}}
|
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
|
||||||
|
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||||
|
}}}
|
||||||
ctx := bot.notificationDestinationContext(context.Background())
|
ctx := bot.notificationDestinationContext(context.Background())
|
||||||
if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil {
|
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)
|
t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err)
|
||||||
|
|||||||
@@ -917,56 +917,6 @@ func TestNotificationArraySecretPreservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
database := openTestStore(t, ":memory:")
|
|
||||||
originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
|
||||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
|
||||||
Channel: "lark", Enabled: true,
|
|
||||||
Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
setting, err := database.NotificationSetting(ctx, "lark")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var redacted map[string]any
|
|
||||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if redacted["url"] != SecretMask || redacted["secret"] != SecretMask {
|
|
||||||
t.Fatalf("redacted Lark config = %#v", redacted)
|
|
||||||
}
|
|
||||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
|
||||||
Channel: "lark", Enabled: true,
|
|
||||||
Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
setting, err = database.NotificationSetting(ctx, "lark")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) {
|
|
||||||
t.Fatalf("stored Lark config = %s", setting.Config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) {
|
|
||||||
setting := NotificationSetting{
|
|
||||||
Config: json.RawMessage(`{"url":"","secret":""}`),
|
|
||||||
SensitiveFields: []string{"url", "secret"},
|
|
||||||
}
|
|
||||||
var redacted map[string]any
|
|
||||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if redacted["url"] != "" || redacted["secret"] != "" {
|
|
||||||
t.Fatalf("empty sensitive values were masked: %#v", redacted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database := openTestStore(t, ":memory:")
|
database := openTestStore(t, ":memory:")
|
||||||
|
|||||||
@@ -610,9 +610,6 @@ func mergeJSONSecrets(
|
|||||||
func redactJSONValue(value any, replacement string) any {
|
func redactJSONValue(value any, replacement string) any {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
if typed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return replacement
|
return replacement
|
||||||
case []any:
|
case []any:
|
||||||
result := make([]any, len(typed))
|
result := make([]any, len(typed))
|
||||||
|
|||||||
+17
-46
@@ -357,11 +357,23 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
||||||
value, err := normalizeDeviceProxyBinding(value)
|
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||||
if err != nil {
|
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||||
return err
|
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||||
|
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||||
|
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||||
|
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||||
}
|
}
|
||||||
_, err = s.db.ExecContext(ctx, `
|
now := time.Now().UTC()
|
||||||
|
createdAt := value.CreatedAt
|
||||||
|
if createdAt.IsZero() {
|
||||||
|
createdAt = now
|
||||||
|
}
|
||||||
|
updatedAt := value.UpdatedAt
|
||||||
|
if updatedAt.IsZero() {
|
||||||
|
updatedAt = now
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO device_proxy_bindings (
|
INSERT INTO device_proxy_bindings (
|
||||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
@@ -370,54 +382,13 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
|||||||
profile_name = excluded.profile_name,
|
profile_name = excluded.profile_name,
|
||||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertDeviceProxyBindingIfAbsent materializes a default route without ever
|
|
||||||
// replacing an explicit (or concurrently-created) ICCID binding.
|
|
||||||
func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) {
|
|
||||||
value, err := normalizeDeviceProxyBinding(value)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
result, err := s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO device_proxy_bindings (
|
|
||||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(iccid) DO NOTHING
|
|
||||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err)
|
|
||||||
}
|
|
||||||
affected, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err)
|
|
||||||
}
|
|
||||||
return affected > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) {
|
|
||||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
|
||||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
|
||||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
|
||||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
|
||||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
|
||||||
return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if value.CreatedAt.IsZero() {
|
|
||||||
value.CreatedAt = now
|
|
||||||
}
|
|
||||||
if value.UpdatedAt.IsZero() {
|
|
||||||
value.UpdatedAt = now
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
||||||
return deviceProxyBinding(s.db.QueryRowContext(
|
return deviceProxyBinding(s.db.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
|||||||
return []string{"token"}
|
return []string{"token"}
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return []string{"urls"}
|
return []string{"urls"}
|
||||||
case "lark":
|
|
||||||
return []string{"url", "secret"}
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package store
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -95,15 +94,9 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Some IMS stacks hand us a cumulative segment: sequence 2 contains the
|
prior, alreadyHad := parts[sequence]
|
||||||
// already-decoded text of sequence 1 followed by its own payload. Keep a
|
changed = !alreadyHad || prior != segmentBody
|
||||||
// snapshot so normalizing that representation remains idempotent on a later
|
|
||||||
// redelivery of the same segment.
|
|
||||||
previousParts := maps.Clone(parts)
|
|
||||||
normalizeCumulativeConcatParts(previousParts)
|
|
||||||
parts[sequence] = segmentBody
|
parts[sequence] = segmentBody
|
||||||
normalizeCumulativeConcatParts(parts)
|
|
||||||
changed = !maps.Equal(previousParts, parts)
|
|
||||||
|
|
||||||
sequences := make([]int, 0, len(parts))
|
sequences := make([]int, 0, len(parts))
|
||||||
for n := range parts {
|
for n := range parts {
|
||||||
@@ -137,24 +130,3 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
return joined.String(), json.RawMessage(encoded), changed, nil
|
return joined.String(), json.RawMessage(encoded), changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeCumulativeConcatParts converts cumulative IMS segment bodies back
|
|
||||||
// into ordinary per-segment bodies. It only removes an exact, non-empty prefix
|
|
||||||
// assembled from every preceding sequence starting at 1, and only when the
|
|
||||||
// current value also contains additional text. That deliberately leaves equal
|
|
||||||
// repeated segments and incomplete/out-of-order prefixes untouched.
|
|
||||||
func normalizeCumulativeConcatParts(parts map[int]string) {
|
|
||||||
var prefix strings.Builder
|
|
||||||
for sequence := 1; ; sequence++ {
|
|
||||||
text, ok := parts[sequence]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assembled := prefix.String()
|
|
||||||
if assembled != "" && len(text) > len(assembled) && strings.HasPrefix(text, assembled) {
|
|
||||||
text = strings.TrimPrefix(text, assembled)
|
|
||||||
parts[sequence] = text
|
|
||||||
}
|
|
||||||
prefix.WriteString(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -101,62 +101,6 @@ func TestMergeConcatSegmentRedeliveryIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeConcatSegmentNormalizesCumulativeIMSPart(t *testing.T) {
|
|
||||||
first := strings.Repeat("安全提醒", 17)
|
|
||||||
want := first + "请通过官方渠道核实。"
|
|
||||||
_, extra, _, err := mergeConcatSegment(nil, first, concatExtra(t, 8, 2, 1))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body, normalized, changed, err := mergeConcatSegment(extra, want, concatExtra(t, 8, 2, 2))
|
|
||||||
if err != nil || !changed {
|
|
||||||
t.Fatalf("cumulative segment: body=%q changed=%v err=%v", body, changed, err)
|
|
||||||
}
|
|
||||||
if body != want {
|
|
||||||
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redelivering the cumulative wire representation must compare equal to the
|
|
||||||
// normalized stored representation and must not churn the durable row id.
|
|
||||||
body, _, changed, err = mergeConcatSegment(normalized, want, concatExtra(t, 8, 2, 2))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if changed || body != want {
|
|
||||||
t.Fatalf("redelivery: body=%q changed=%v, want %q/false", body, changed, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeConcatSegmentNormalizesCumulativeIMSPartOutOfOrder(t *testing.T) {
|
|
||||||
first := strings.Repeat("甲", 67)
|
|
||||||
want := first + "尾段"
|
|
||||||
_, extra, _, err := mergeConcatSegment(nil, want, concatExtra(t, 12, 2, 2))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body, _, changed, err := mergeConcatSegment(extra, first, concatExtra(t, 12, 2, 1))
|
|
||||||
if err != nil || !changed {
|
|
||||||
t.Fatalf("out-of-order segment: body=%q changed=%v err=%v", body, changed, err)
|
|
||||||
}
|
|
||||||
if body != want {
|
|
||||||
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeConcatSegmentKeepsEqualRepeatedPart(t *testing.T) {
|
|
||||||
_, extra, _, err := mergeConcatSegment(nil, "重复", concatExtra(t, 13, 2, 1))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body, _, _, err := mergeConcatSegment(extra, "重复", concatExtra(t, 13, 2, 2))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if body != "重复重复" {
|
|
||||||
t.Fatalf("body = %q, want intentional equal segments preserved", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
||||||
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLongSMSReassemblySurvivesServiceRestart(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
|
||||||
const (
|
|
||||||
deviceID = "dajiang"
|
|
||||||
imei = "867394042309830"
|
|
||||||
peer = "+447700900123"
|
|
||||||
)
|
|
||||||
messageID := StableConcatMessageID("ims", imei, deviceID, peer, 27, 2)
|
|
||||||
|
|
||||||
database, err := Open(ctx, path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
mustSaveDevice(t, database, deviceID, "大疆")
|
|
||||||
first, err := database.SaveSMSMessage(ctx, SMSMessage{
|
|
||||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
|
||||||
Peer: peer, Direction: "inbound", Body: "第一段:安全提醒,",
|
|
||||||
Timestamp: time.Unix(1_700_000_000, 0).UTC(), Status: "received", Source: "ims",
|
|
||||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 1),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
|
|
||||||
t.Fatal("partial message must not be ready before restart")
|
|
||||||
}
|
|
||||||
if err := database.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
database, err = Open(ctx, path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
second, err := database.SaveSMSMessage(ctx, SMSMessage{
|
|
||||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
|
||||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
|
||||||
Timestamp: time.Unix(1_700_000_030, 0).UTC(), Status: "received", Source: "ims",
|
|
||||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if second.Body != "第一段:安全提醒,第二段:请通过官方渠道核实。" ||
|
|
||||||
!ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
|
|
||||||
t.Fatalf("reassembled message after restart = %#v", second)
|
|
||||||
}
|
|
||||||
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: deviceID})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(messages) != 1 || messages[0].ID != second.ID {
|
|
||||||
t.Fatalf("stored messages after restart = %#v, want one merged row", messages)
|
|
||||||
}
|
|
||||||
redelivered, err := database.SaveSMSMessage(ctx, SMSMessage{
|
|
||||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
|
||||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
|
||||||
Status: "received", Source: "ims", PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if redelivered.ID != second.ID || redelivered.Body != second.Body {
|
|
||||||
t.Fatalf("redelivery duplicated or changed message: %#v", redelivered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipartDeliveryReportsSurviveServiceRestart(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
|
||||||
database, err := Open(ctx, path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
mustSaveDevice(t, database, "dajiang", "大疆")
|
|
||||||
extra := json.RawMessage(`{"transport":"ims","part_results":[{"reference":51},{"reference":52}]}`)
|
|
||||||
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
|
|
||||||
MessageID: "ims-submit-restart", DeviceID: "dajiang", IMSI: "23433",
|
|
||||||
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
|
|
||||||
Status: "accepted_by_ims", Source: "ims", PartsTotal: 2,
|
|
||||||
DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := database.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
database, err = Open(ctx, path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
|
||||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
|
||||||
MessageReference: 51, StatusCode: 0, DeliveryState: "delivered",
|
|
||||||
})
|
|
||||||
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
|
|
||||||
t.Fatalf("first report after restart = (%#v, %v)", first, err)
|
|
||||||
}
|
|
||||||
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
|
||||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
|
||||||
MessageReference: 52, StatusCode: 0, DeliveryState: "delivered",
|
|
||||||
})
|
|
||||||
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
|
|
||||||
t.Fatalf("second report after restart = (%#v, %v)", second, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"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,14 +2,11 @@ package update
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Release mirrors the subset of the GitHub releases API response that the
|
// Release mirrors the subset of the GitHub releases API response that the
|
||||||
@@ -43,23 +40,6 @@ const (
|
|||||||
DefaultRepository = "MengMengCode/VoCat"
|
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
|
// LatestRelease fetches the newest published release for repo (form
|
||||||
// "owner/name"). A non-empty token is sent as a Bearer header, which is
|
// "owner/name"). A non-empty token is sent as a Bearer header, which is
|
||||||
// required for private repositories and lifts the unauthenticated rate limit.
|
// 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)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := githubHTTPClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update: fetch latest release: %w", err)
|
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 != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
resp, err := githubHTTPClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update: download asset: %w", err)
|
return fmt.Errorf("update: download asset: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
package update
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDetectSystemdUnitUsesExplicitOverride(t *testing.T) {
|
|
||||||
t.Setenv("VOCAT_SYSTEMD_UNIT", "vocat-test.service")
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
||||||
if got := detectSystemdUnit(logger); got != "vocat-test.service" {
|
|
||||||
t.Fatalf("detectSystemdUnit() = %q, want vocat-test.service", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdUnitFromCgroup(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "cgroup v2", data: "0::/system.slice/vocat-test.service\n", want: "vocat-test.service"},
|
|
||||||
{name: "legacy", data: "1:name=systemd:/system.slice/[email protected]\n", want: "[email protected]"},
|
|
||||||
{name: "no service", data: "0::/user.slice/user-1000.slice/session-1.scope\n", want: ""},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
if got := systemdUnitFromCgroup(test.data); got != test.want {
|
|
||||||
t.Fatalf("systemdUnitFromCgroup() = %q, want %q", got, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidSystemdUnitRejectsArgumentsAndPaths(t *testing.T) {
|
|
||||||
for _, value := range []string{"vocat", "../vocat.service", "vocat.service --now", "vocat.service/other"} {
|
|
||||||
if validSystemdUnit.MatchString(value) {
|
|
||||||
t.Fatalf("validSystemdUnit unexpectedly accepted %q", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-115
@@ -15,16 +15,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/buildinfo"
|
"vocat/internal/buildinfo"
|
||||||
@@ -153,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)
|
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()
|
cleanup()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -210,68 +206,6 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
|||||||
return nil
|
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
|
// validateExecutable catches incompatible architectures and missing dynamic
|
||||||
// loaders before the working installation is touched. A valid checksum alone
|
// loaders before the working installation is touched. A valid checksum alone
|
||||||
// cannot detect those packaging errors.
|
// cannot detect those packaging errors.
|
||||||
@@ -322,63 +256,17 @@ func RestartService(logger *slog.Logger) error {
|
|||||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||||
}
|
}
|
||||||
unit := detectSystemdUnit(logger)
|
|
||||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||||
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||||
return fmt.Errorf("systemctl restart %s: %w", unit, err)
|
return fmt.Errorf("systemctl restart vocat: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var validSystemdUnit = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`)
|
|
||||||
|
|
||||||
func detectSystemdUnit(logger *slog.Logger) string {
|
|
||||||
if configured := strings.TrimSpace(os.Getenv("VOCAT_SYSTEMD_UNIT")); validSystemdUnit.MatchString(configured) {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
|
||||||
if unit := systemdUnitFromCgroup(string(data)); unit != "" {
|
|
||||||
return unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Some cgroup namespaces hide the unit name. Query loaded services and
|
|
||||||
// identify the unit whose MainPID is this process before falling back.
|
|
||||||
list := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain")
|
|
||||||
if output, err := list.Output(); err == nil {
|
|
||||||
pid := strconv.Itoa(os.Getpid())
|
|
||||||
for _, line := range strings.Split(string(output), "\n") {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) == 0 || !validSystemdUnit.MatchString(fields[0]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
show := exec.Command("systemctl", "show", fields[0], "--property=MainPID", "--value")
|
|
||||||
if value, showErr := show.Output(); showErr == nil && strings.TrimSpace(string(value)) == pid {
|
|
||||||
return fields[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if logger != nil {
|
|
||||||
logger.Warn("could not identify the current systemd unit; using vocat.service", "hint", "set VOCAT_SYSTEMD_UNIT for a custom unit")
|
|
||||||
}
|
|
||||||
return "vocat.service"
|
|
||||||
}
|
|
||||||
|
|
||||||
func systemdUnitFromCgroup(data string) string {
|
|
||||||
for _, line := range strings.Split(data, "\n") {
|
|
||||||
for _, part := range strings.Split(line, "/") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if validSystemdUnit.MatchString(part) {
|
|
||||||
return part
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveDefaultTarget returns the conventional install path when present,
|
// resolveDefaultTarget returns the conventional install path when present,
|
||||||
// falling back to the running executable. This lets `vocat update` "just work"
|
// falling back to the running executable. This lets `vocat update` "just work"
|
||||||
// on the standard systemd host without flags.
|
// on the standard systemd host without flags.
|
||||||
|
|||||||
@@ -1,569 +1,52 @@
|
|||||||
package vowifi
|
package vowifi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const att310280EPDG = "epdg.epc.att.net"
|
||||||
CarrierProfileSchemaVersion = 1
|
|
||||||
CarrierProfileStandard = "standard-3gpp"
|
|
||||||
IKEProposalModern = "modern"
|
|
||||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
|
||||||
IMSProfileStandard = "standard"
|
|
||||||
IMSProfileO2Germany = "o2-germany"
|
|
||||||
IMSProfileATT = "att"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CarrierProfile contains only interoperability choices that cannot be
|
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
||||||
// reliably discovered from the SIM or negotiated with the network. All
|
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
||||||
// protocol layers consume this common result so their carrier handling cannot
|
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
||||||
// drift into separate MCC/MNC switch statements.
|
// VoWiFi access gateway.
|
||||||
type CarrierProfile struct {
|
|
||||||
ID string
|
|
||||||
MatchSource string
|
|
||||||
RouteMCC string
|
|
||||||
RouteMNC string
|
|
||||||
EPDG string
|
|
||||||
IKEProposal string
|
|
||||||
AdvertiseEAPOnly bool
|
|
||||||
IMSTransport string
|
|
||||||
IMSIdentityProfile string
|
|
||||||
IMSRegisterProfile string
|
|
||||||
IMSIPSecEncryption string
|
|
||||||
SMSCenter string
|
|
||||||
PANICountry string
|
|
||||||
PANINode string
|
|
||||||
IMSDialURIScheme string
|
|
||||||
IMSUserEqPhone bool
|
|
||||||
IMSVoiceCodecs []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type carrierProfileDocument struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Profiles []carrierProfileRule `json:"profiles"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type carrierProfileRule struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Match carrierProfileMatch `json:"match,omitzero"`
|
|
||||||
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
|
||||||
Route carrierProfileRoute `json:"route,omitzero"`
|
|
||||||
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
|
||||||
IKE carrierProfileIKE `json:"ike,omitzero"`
|
|
||||||
IMS carrierProfileIMS `json:"ims,omitzero"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type carrierProfileMatch struct {
|
|
||||||
HomePLMNs []string `json:"home_plmns,omitempty"`
|
|
||||||
IMSIPrefixes []string `json:"imsi_prefixes,omitempty"`
|
|
||||||
ICCIDPrefixes []string `json:"iccid_prefixes,omitempty"`
|
|
||||||
SPNs []string `json:"spns,omitempty"`
|
|
||||||
GID1Prefixes []string `json:"gid1_prefixes,omitempty"`
|
|
||||||
GID2Prefixes []string `json:"gid2_prefixes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type carrierProfileRoute struct {
|
|
||||||
MCC string `json:"mcc,omitempty"`
|
|
||||||
MNC string `json:"mnc,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type carrierProfileEPDG struct {
|
|
||||||
Hostname string `json:"hostname,omitempty"`
|
|
||||||
DNSHosts []string `json:"dns_hosts,omitempty"`
|
|
||||||
DNSClientSubnet string `json:"dns_client_subnet,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type carrierProfileIKE struct {
|
|
||||||
Proposal string `json:"proposal,omitempty"`
|
|
||||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type carrierProfileIMS struct {
|
|
||||||
Transport string `json:"transport,omitempty"`
|
|
||||||
IdentityProfile string `json:"identity_profile,omitempty"`
|
|
||||||
RegisterProfile string `json:"register_profile,omitempty"`
|
|
||||||
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
|
||||||
SMSCenter string `json:"sms_center,omitempty"`
|
|
||||||
PANICountry string `json:"pani_country,omitempty"`
|
|
||||||
PANINode string `json:"pani_node,omitempty"`
|
|
||||||
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
|
||||||
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
|
||||||
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
//go:embed carrier_profiles.json
|
|
||||||
var carrierProfilesJSON []byte
|
|
||||||
|
|
||||||
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
|
||||||
|
|
||||||
var externalCarrierProfiles = struct {
|
|
||||||
sync.RWMutex
|
|
||||||
rules []carrierProfileRule
|
|
||||||
}{}
|
|
||||||
|
|
||||||
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
|
||||||
rules, err := loadCarrierProfiles(encoded)
|
|
||||||
if err != nil {
|
|
||||||
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
|
||||||
}
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCarrierProfiles(encoded []byte) ([]carrierProfileRule, error) {
|
|
||||||
var document carrierProfileDocument
|
|
||||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if document.Version != CarrierProfileSchemaVersion {
|
|
||||||
return nil, fmt.Errorf("unsupported carrier profile version %d", document.Version)
|
|
||||||
}
|
|
||||||
seen := make(map[string]struct{}, len(document.Profiles))
|
|
||||||
for index := range document.Profiles {
|
|
||||||
rule := &document.Profiles[index]
|
|
||||||
rule.ID = strings.TrimSpace(rule.ID)
|
|
||||||
if rule.ID == "" {
|
|
||||||
return nil, fmt.Errorf("carrier profile %d ID is empty", index)
|
|
||||||
}
|
|
||||||
if _, duplicate := seen[rule.ID]; duplicate {
|
|
||||||
return nil, errors.New("duplicate carrier profile " + rule.ID)
|
|
||||||
}
|
|
||||||
seen[rule.ID] = struct{}{}
|
|
||||||
if !validCarrierProfileRule(*rule) {
|
|
||||||
return nil, errors.New("invalid carrier profile " + rule.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return document.Profiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadCarrierProfileDirectory replaces the installed profile set with all
|
|
||||||
// valid JSON documents in dir. A missing directory is an empty set. Profiles
|
|
||||||
// are sorted by filename; later profiles win only when selector specificity is
|
|
||||||
// equal, so a broad installed PLMN rule cannot hide a constrained MVNO rule.
|
|
||||||
func LoadCarrierProfileDirectory(dir string) error {
|
|
||||||
dir = strings.TrimSpace(dir)
|
|
||||||
if dir == "" {
|
|
||||||
return errors.New("carrier profile directory is empty")
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
externalCarrierProfiles.Lock()
|
|
||||||
externalCarrierProfiles.rules = nil
|
|
||||||
externalCarrierProfiles.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read carrier profile directory %q: %w", dir, err)
|
|
||||||
}
|
|
||||||
if len(entries) > 256 {
|
|
||||||
return fmt.Errorf("carrier profile directory %q contains %d entries; maximum is 256", dir, len(entries))
|
|
||||||
}
|
|
||||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
|
||||||
loaded := make([]carrierProfileRule, 0, len(entries))
|
|
||||||
seen := make(map[string]string)
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := filepath.Join(dir, entry.Name())
|
|
||||||
info, err := entry.Info()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("stat carrier profile %q: %w", path, err)
|
|
||||||
}
|
|
||||||
if info.Size() > 1<<20 {
|
|
||||||
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
|
||||||
}
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open carrier profile %q: %w", path, err)
|
|
||||||
}
|
|
||||||
encoded, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
|
||||||
closeErr := file.Close()
|
|
||||||
if readErr != nil {
|
|
||||||
return fmt.Errorf("read carrier profile %q: %w", path, readErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
return fmt.Errorf("close carrier profile %q: %w", path, closeErr)
|
|
||||||
}
|
|
||||||
if len(encoded) > 1<<20 {
|
|
||||||
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
|
||||||
}
|
|
||||||
rules, err := loadCarrierProfiles(encoded)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("load carrier profile %q: %w", path, err)
|
|
||||||
}
|
|
||||||
for _, rule := range rules {
|
|
||||||
if previous := seen[rule.ID]; previous != "" {
|
|
||||||
return fmt.Errorf("carrier profile %q is duplicated in %q and %q", rule.ID, previous, path)
|
|
||||||
}
|
|
||||||
seen[rule.ID] = path
|
|
||||||
loaded = append(loaded, rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
externalCarrierProfiles.Lock()
|
|
||||||
externalCarrierProfiles.rules = loaded
|
|
||||||
externalCarrierProfiles.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func carrierProfilesSnapshot() []carrierProfileRule {
|
|
||||||
externalCarrierProfiles.RLock()
|
|
||||||
defer externalCarrierProfiles.RUnlock()
|
|
||||||
result := make([]carrierProfileRule, 0, len(builtinCarrierProfiles)+len(externalCarrierProfiles.rules))
|
|
||||||
result = append(result, builtinCarrierProfiles...)
|
|
||||||
result = append(result, externalCarrierProfiles.rules...)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
|
||||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
|
||||||
if !emptyCarrierProfileMatch(rule.Match) {
|
|
||||||
matches = append(matches, rule.Match)
|
|
||||||
}
|
|
||||||
matches = append(matches, rule.MatchAny...)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, match := range matches {
|
|
||||||
if emptyCarrierProfileMatch(match) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, plmn := range match.HomePLMNs {
|
|
||||||
if canonicalPLMNValue(plmn) == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, prefix := range match.IMSIPrefixes {
|
|
||||||
if len(prefix) < 5 || len(prefix) > 18 || !decimalString(prefix) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, prefix := range match.ICCIDPrefixes {
|
|
||||||
if len(prefix) < 5 || len(prefix) > 22 || !decimalString(prefix) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, prefix := range append(append([]string(nil), match.GID1Prefixes...), match.GID2Prefixes...) {
|
|
||||||
if len(prefix) < 1 || len(prefix) > 64 || !hexString(prefix) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, spn := range match.SPNs {
|
|
||||||
if strings.TrimSpace(spn) == "" || len(spn) > 128 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
|
||||||
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if proposal := strings.TrimSpace(rule.IKE.Proposal); proposal != "" &&
|
|
||||||
proposal != IKEProposalModern && proposal != IKEProposalLegacy {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if transport := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); transport != "" &&
|
|
||||||
transport != "tcp" && transport != "udp" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if encryption := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); encryption != "" &&
|
|
||||||
encryption != "aes-cbc" && encryption != "null" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
|
||||||
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, codec := range rule.IMS.VoiceCodecs {
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(codec)) {
|
|
||||||
case "PCMA", "PCMU", "AMR", "AMR-WB":
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func emptyCarrierProfileMatch(match carrierProfileMatch) bool {
|
|
||||||
return len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
|
||||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func hexString(value string) bool {
|
|
||||||
for _, item := range value {
|
|
||||||
if item >= '0' && item <= '9' || item >= 'a' && item <= 'f' || item >= 'A' && item <= 'F' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return value != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func decimalString(value string) bool {
|
|
||||||
if value == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, item := range value {
|
|
||||||
if item < '0' || item > '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
|
||||||
// attributes add specificity, so a constrained MVNO rule wins over its host
|
|
||||||
// PLMN without weakening the default match for unrelated subscriptions.
|
|
||||||
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|
||||||
resolved := CarrierProfile{
|
|
||||||
ID: CarrierProfileStandard,
|
|
||||||
MatchSource: "standard",
|
|
||||||
IKEProposal: IKEProposalModern,
|
|
||||||
AdvertiseEAPOnly: true,
|
|
||||||
IMSIdentityProfile: IMSProfileStandard,
|
|
||||||
IMSRegisterProfile: IMSProfileStandard,
|
|
||||||
IMSIPSecEncryption: "aes-cbc",
|
|
||||||
IMSDialURIScheme: "tel",
|
|
||||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
|
||||||
}
|
|
||||||
bestScore := -1
|
|
||||||
for _, rule := range carrierProfilesSnapshot() {
|
|
||||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
|
||||||
if !matched || score < bestScore {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bestScore = score
|
|
||||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchCarrierProfileRule evaluates each selector set as an alternative. This
|
|
||||||
// mirrors carrier-bundle and Android carrier-ID semantics: fields inside one
|
|
||||||
// selector are ANDed, while separate selector records for the same brand are
|
|
||||||
// ORed (for example, giffgaff can be identified by either GID1 or SPN).
|
|
||||||
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
|
||||||
bestScore := -1
|
|
||||||
bestSource := ""
|
|
||||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
|
||||||
if !emptyCarrierProfileMatch(rule.Match) {
|
|
||||||
matches = append(matches, rule.Match)
|
|
||||||
}
|
|
||||||
matches = append(matches, rule.MatchAny...)
|
|
||||||
for _, match := range matches {
|
|
||||||
score, source, matched := matchCarrierProfile(match, identity)
|
|
||||||
if matched && score > bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestSource = source
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestScore, bestSource, bestScore >= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
|
||||||
score := 0
|
|
||||||
sources := make([]string, 0, 6)
|
|
||||||
if len(match.HomePLMNs) > 0 {
|
|
||||||
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
|
||||||
if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool {
|
|
||||||
return canonicalPLMNValue(value) == wanted
|
|
||||||
}) {
|
|
||||||
return 0, "", false
|
|
||||||
}
|
|
||||||
score += 100
|
|
||||||
sources = append(sources, "hplmn")
|
|
||||||
}
|
|
||||||
for _, selector := range []struct {
|
|
||||||
name string
|
|
||||||
weight int
|
|
||||||
values []string
|
|
||||||
actual string
|
|
||||||
foldCase bool
|
|
||||||
}{
|
|
||||||
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
|
|
||||||
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
|
|
||||||
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
|
|
||||||
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
|
|
||||||
} {
|
|
||||||
if len(selector.values) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
actual := strings.TrimSpace(selector.actual)
|
|
||||||
if actual == "" || !matchesAny(selector.values, func(prefix string) bool {
|
|
||||||
prefix = strings.TrimSpace(prefix)
|
|
||||||
if selector.foldCase {
|
|
||||||
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
|
||||||
}
|
|
||||||
return strings.HasPrefix(actual, prefix)
|
|
||||||
}) {
|
|
||||||
return 0, "", false
|
|
||||||
}
|
|
||||||
score += selector.weight
|
|
||||||
sources = append(sources, selector.name)
|
|
||||||
}
|
|
||||||
if len(match.SPNs) > 0 {
|
|
||||||
spn := strings.TrimSpace(identity.SPN)
|
|
||||||
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
|
|
||||||
return strings.EqualFold(strings.TrimSpace(value), spn)
|
|
||||||
}) {
|
|
||||||
return 0, "", false
|
|
||||||
}
|
|
||||||
score += 20
|
|
||||||
sources = append(sources, "spn")
|
|
||||||
}
|
|
||||||
return score, strings.Join(sources, "+"), score > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchesAny(values []string, match func(string) bool) bool {
|
|
||||||
for _, value := range values {
|
|
||||||
if match(value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
|
|
||||||
base.ID = rule.ID
|
|
||||||
base.MatchSource = source
|
|
||||||
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
|
||||||
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
|
||||||
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
|
||||||
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
|
||||||
base.IKEProposal = value
|
|
||||||
}
|
|
||||||
if rule.IKE.AdvertiseEAPOnly != nil {
|
|
||||||
base.AdvertiseEAPOnly = *rule.IKE.AdvertiseEAPOnly
|
|
||||||
}
|
|
||||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); value != "" {
|
|
||||||
base.IMSTransport = value
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(rule.IMS.IdentityProfile); value != "" {
|
|
||||||
base.IMSIdentityProfile = value
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(rule.IMS.RegisterProfile); value != "" {
|
|
||||||
base.IMSRegisterProfile = value
|
|
||||||
}
|
|
||||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); value != "" {
|
|
||||||
base.IMSIPSecEncryption = value
|
|
||||||
}
|
|
||||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
|
||||||
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
|
||||||
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
|
||||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
|
||||||
base.IMSDialURIScheme = value
|
|
||||||
}
|
|
||||||
if rule.IMS.UserEqPhone != nil {
|
|
||||||
base.IMSUserEqPhone = *rule.IMS.UserEqPhone
|
|
||||||
}
|
|
||||||
if len(rule.IMS.VoiceCodecs) > 0 {
|
|
||||||
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeVoiceCodecs(values []string) []string {
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
seen := make(map[string]struct{}, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, duplicate := seen[value]; duplicate {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
result = append(result, value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalPLMN(mcc, mnc string) string {
|
|
||||||
mcc = strings.TrimSpace(mcc)
|
|
||||||
mnc = strings.TrimSpace(mnc)
|
|
||||||
if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for len(mnc) < 3 {
|
|
||||||
mnc = "0" + mnc
|
|
||||||
}
|
|
||||||
return mcc + mnc
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalPLMNValue(value string) string {
|
|
||||||
value = strings.TrimSpace(strings.ReplaceAll(value, "/", ""))
|
|
||||||
if len(value) != 5 && len(value) != 6 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return canonicalPLMN(value[:3], value[3:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssignedRoutePLMN remains available to callers that only have the legacy
|
|
||||||
// identifier pair. New code resolves the complete SIMIdentity so SPN/GID
|
|
||||||
// selectors can participate.
|
|
||||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||||
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
|
iccid = strings.TrimSpace(iccid)
|
||||||
if len(identity.IMSI) >= 5 {
|
imsi = strings.TrimSpace(imsi)
|
||||||
identity.HomeMCC = identity.IMSI[:3]
|
switch {
|
||||||
for _, length := range []int{3, 2} {
|
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
||||||
if len(identity.IMSI) < 3+length {
|
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
||||||
continue
|
return "234", "15", true
|
||||||
}
|
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
||||||
identity.HomeMNC = identity.IMSI[3 : 3+length]
|
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
||||||
profile := ResolveCarrierProfile(identity)
|
// the initial VoWiFi provisioning path.
|
||||||
if profile.RouteMCC != "" {
|
return "234", "30", true
|
||||||
return profile.RouteMCC, profile.RouteMNC, true
|
default:
|
||||||
}
|
return "", "", false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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 {
|
func IsATT310280(identity SIMIdentity) bool {
|
||||||
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
|
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 {
|
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||||
if strings.TrimSpace(identity.EPDG) != "" {
|
if strings.TrimSpace(identity.EPDG) != "" {
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
profile := ResolveCarrierProfile(identity)
|
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
||||||
switch {
|
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
||||||
case profile.EPDG != "":
|
|
||||||
identity.EPDG = profile.EPDG
|
|
||||||
case profile.RouteMCC != "":
|
|
||||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
|
||||||
}
|
}
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
|
|
||||||
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
|
||||||
// ePDG whose authoritative DNS only exposes addresses to home-country
|
|
||||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
|
||||||
func EPDGDNSClientSubnet(host string) string {
|
|
||||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
|
||||||
for _, rule := range carrierProfilesSnapshot() {
|
|
||||||
for _, candidate := range rule.EPDG.DNSHosts {
|
|
||||||
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
|
||||||
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func standardEPDGHostname(mcc, mnc string) string {
|
func standardEPDGHostname(mcc, mnc string) string {
|
||||||
mnc = strings.TrimSpace(mnc)
|
mnc = strings.TrimSpace(mnc)
|
||||||
for len(mnc) < 3 {
|
for len(mnc) < 3 {
|
||||||
|
|||||||
@@ -54,94 +54,3 @@ func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
|
||||||
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
|
||||||
})
|
|
||||||
if profile.ID != CarrierProfileStandard || profile.MatchSource != "standard" {
|
|
||||||
t.Fatalf("default profile = %#v", profile)
|
|
||||||
}
|
|
||||||
if profile.IKEProposal != IKEProposalModern || !profile.AdvertiseEAPOnly ||
|
|
||||||
profile.IMSIdentityProfile != IMSProfileStandard || profile.IMSRegisterProfile != IMSProfileStandard {
|
|
||||||
t.Fatalf("default profile lost standard capabilities: %#v", profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
|
||||||
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
|
||||||
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
|
||||||
})
|
|
||||||
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
|
||||||
t.Fatalf("MVNO profile = %#v", profile)
|
|
||||||
}
|
|
||||||
if profile.MatchSource != "hplmn+imsi+iccid" {
|
|
||||||
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
identity SIMIdentity
|
|
||||||
source string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Apple GID1 selector",
|
|
||||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
|
|
||||||
source: "hplmn+gid1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Android SPN selector",
|
|
||||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"},
|
|
||||||
source: "hplmn+spn",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(test.identity)
|
|
||||||
if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source {
|
|
||||||
t.Fatalf("giffgaff profile = %#v", profile)
|
|
||||||
}
|
|
||||||
if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone {
|
|
||||||
t.Fatalf("giffgaff IMS settings = %#v", profile)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
generic := ResolveCarrierProfile(SIMIdentity{
|
|
||||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10",
|
|
||||||
})
|
|
||||||
if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" {
|
|
||||||
t.Fatalf("generic O2 profile = %#v", generic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
|
||||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
|
||||||
HomeMCC: "234", HomeMNC: "33",
|
|
||||||
})
|
|
||||||
if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" {
|
|
||||||
t.Fatalf("EE-hosted profile = %#v", profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
|
||||||
for _, mnc := range []string{"03", "003"} {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
|
||||||
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
|
||||||
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
|
||||||
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
|
||||||
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
|
||||||
}
|
|
||||||
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
|
||||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,850 +0,0 @@
|
|||||||
package vowifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"howett.net/plist"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxIPCCBytes = 32 << 20
|
|
||||||
maxIPCCFiles = 512
|
|
||||||
maxIPCCPlistBytes = 4 << 20
|
|
||||||
maxIPCCPlistTotalBytes = 64 << 20
|
|
||||||
installedProfileFileMode = 0o600
|
|
||||||
)
|
|
||||||
|
|
||||||
var supportedSIMPLMN = regexp.MustCompile(`^[0-9]{5,6}$`)
|
|
||||||
|
|
||||||
// IPCCImportOptions controls deterministic bundle selection and profile ID
|
|
||||||
// generation. Bundle may be a full archive directory or the final .bundle
|
|
||||||
// name. ProfileID overrides the generated, filesystem-safe ID.
|
|
||||||
type IPCCImportOptions struct {
|
|
||||||
Bundle string
|
|
||||||
ProfileID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPCCImportWarning describes a value that was ambiguous, unsafe, or outside
|
|
||||||
// VoCat's portable carrier-profile schema. Such values are reported but never
|
|
||||||
// copied into the installed profile.
|
|
||||||
type IPCCImportWarning struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPCCImportResult contains a reviewable carrier-profile document. Document
|
|
||||||
// is complete JSON and can be installed without retaining the Apple archive.
|
|
||||||
type IPCCImportResult struct {
|
|
||||||
SourceFile string `json:"source_file"`
|
|
||||||
SourceSHA256 string `json:"source_sha256"`
|
|
||||||
Bundle string `json:"bundle"`
|
|
||||||
CarrierName string `json:"carrier_name"`
|
|
||||||
ProfileID string `json:"profile_id"`
|
|
||||||
Document json.RawMessage `json:"document"`
|
|
||||||
Warnings []IPCCImportWarning `json:"warnings,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ipccPlist struct {
|
|
||||||
name string
|
|
||||||
root map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
type ipccWarningSet struct {
|
|
||||||
items []IPCCImportWarning
|
|
||||||
seen map[string]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (set *ipccWarningSet) add(code, message, plistPath string) {
|
|
||||||
if set.seen == nil {
|
|
||||||
set.seen = make(map[string]struct{})
|
|
||||||
}
|
|
||||||
item := IPCCImportWarning{Code: code, Message: message, Path: plistPath}
|
|
||||||
// Device-family override plists often repeat the same setting. Preserve the
|
|
||||||
// first concrete path while keeping the review output compact.
|
|
||||||
key := code + "\x00" + message
|
|
||||||
if _, duplicate := set.seen[key]; duplicate {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
set.seen[key] = struct{}{}
|
|
||||||
set.items = append(set.items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportCarrierIPCC converts a local Apple .ipcc/.zip archive into one
|
|
||||||
// reviewable VoCat carrier profile. It never contacts Apple and never installs the
|
|
||||||
// result. Device-specific and security-weakening values are deliberately
|
|
||||||
// omitted with structured warnings.
|
|
||||||
func ImportCarrierIPCC(filePath string, options IPCCImportOptions) (IPCCImportResult, error) {
|
|
||||||
filePath = strings.TrimSpace(filePath)
|
|
||||||
if filePath == "" {
|
|
||||||
return IPCCImportResult{}, errors.New("IPCC path is empty")
|
|
||||||
}
|
|
||||||
info, err := os.Stat(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("stat IPCC %q: %w", filePath, err)
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("IPCC %q is not a regular file", filePath)
|
|
||||||
}
|
|
||||||
if info.Size() <= 0 || info.Size() > maxIPCCBytes {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("IPCC %q size %d is outside 1..%d bytes", filePath, info.Size(), maxIPCCBytes)
|
|
||||||
}
|
|
||||||
encoded, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("read IPCC %q: %w", filePath, err)
|
|
||||||
}
|
|
||||||
archive, err := zip.NewReader(bytes.NewReader(encoded), int64(len(encoded)))
|
|
||||||
if err != nil {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("open IPCC %q: %w", filePath, err)
|
|
||||||
}
|
|
||||||
if len(archive.File) > maxIPCCFiles {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("IPCC contains %d files; maximum is %d", len(archive.File), maxIPCCFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
bundleRoots := carrierBundleRoots(archive.File)
|
|
||||||
bundleRoot, err := selectCarrierBundle(bundleRoots, options.Bundle)
|
|
||||||
if err != nil {
|
|
||||||
return IPCCImportResult{}, err
|
|
||||||
}
|
|
||||||
plists, err := readCarrierBundlePlists(archive.File, bundleRoot)
|
|
||||||
if err != nil {
|
|
||||||
return IPCCImportResult{}, err
|
|
||||||
}
|
|
||||||
primary := plists[0]
|
|
||||||
warnings := &ipccWarningSet{}
|
|
||||||
carrierName := firstNonempty(
|
|
||||||
plistString(primary.root["CarrierName"]),
|
|
||||||
statusBarCarrierName(primary.root),
|
|
||||||
strings.TrimSuffix(path.Base(bundleRoot), path.Ext(bundleRoot)),
|
|
||||||
)
|
|
||||||
|
|
||||||
matches, plmns, err := importCarrierSelectors(primary.root, plists, warnings)
|
|
||||||
if err != nil {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("import selectors from %s: %w", primary.name, err)
|
|
||||||
}
|
|
||||||
profileID := strings.TrimSpace(options.ProfileID)
|
|
||||||
if profileID == "" {
|
|
||||||
profileID = generatedIPCCProfileID(carrierName, plmns)
|
|
||||||
}
|
|
||||||
if !validInstalledProfileID(profileID) {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("profile ID %q must match [a-z0-9][a-z0-9._-]{0,63}", profileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rule := carrierProfileRule{ID: profileID}
|
|
||||||
if len(matches) == 1 {
|
|
||||||
rule.Match = matches[0]
|
|
||||||
} else {
|
|
||||||
rule.MatchAny = matches
|
|
||||||
}
|
|
||||||
importCarrierEPDG(&rule, plists, warnings)
|
|
||||||
importCarrierIKE(&rule, plists, warnings)
|
|
||||||
importCarrierIMS(&rule, plists, warnings)
|
|
||||||
inspectIgnoredCarrierFields(plists, warnings)
|
|
||||||
if !validCarrierProfileRule(rule) {
|
|
||||||
return IPCCImportResult{}, errors.New("converted IPCC profile is not valid")
|
|
||||||
}
|
|
||||||
|
|
||||||
sum := sha256.Sum256(encoded)
|
|
||||||
document := struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Metadata map[string]string `json:"metadata"`
|
|
||||||
Profiles []carrierProfileRule `json:"profiles"`
|
|
||||||
}{
|
|
||||||
Version: CarrierProfileSchemaVersion,
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"source": "user-supplied Apple carrier bundle",
|
|
||||||
"source_sha256": hex.EncodeToString(sum[:]),
|
|
||||||
"bundle": bundleRoot,
|
|
||||||
"generated_by": "vocat carrier import-ipcc",
|
|
||||||
},
|
|
||||||
Profiles: []carrierProfileRule{rule},
|
|
||||||
}
|
|
||||||
documentJSON, err := json.MarshalIndent(document, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return IPCCImportResult{}, fmt.Errorf("encode imported carrier profile: %w", err)
|
|
||||||
}
|
|
||||||
return IPCCImportResult{
|
|
||||||
SourceFile: filepath.Base(filePath),
|
|
||||||
SourceSHA256: hex.EncodeToString(sum[:]),
|
|
||||||
Bundle: bundleRoot,
|
|
||||||
CarrierName: carrierName,
|
|
||||||
ProfileID: profileID,
|
|
||||||
Document: append(documentJSON, '\n'),
|
|
||||||
Warnings: warnings.items,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallCarrierIPCCResult atomically writes an already-reviewed import result
|
|
||||||
// to dir. Existing files are never replaced; importing an update therefore
|
|
||||||
// requires an explicit operator decision outside this function.
|
|
||||||
func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, error) {
|
|
||||||
if !validInstalledProfileID(result.ProfileID) {
|
|
||||||
return "", fmt.Errorf("invalid profile ID %q", result.ProfileID)
|
|
||||||
}
|
|
||||||
if len(result.Document) == 0 {
|
|
||||||
return "", errors.New("import result has no profile document")
|
|
||||||
}
|
|
||||||
if _, err := loadCarrierProfiles(result.Document); err != nil {
|
|
||||||
return "", fmt.Errorf("validate imported profile: %w", err)
|
|
||||||
}
|
|
||||||
dir = strings.TrimSpace(dir)
|
|
||||||
if dir == "" {
|
|
||||||
return "", errors.New("carrier profile directory is empty")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
||||||
return "", fmt.Errorf("create carrier profile directory %q: %w", dir, err)
|
|
||||||
}
|
|
||||||
target := filepath.Join(dir, result.ProfileID+".json")
|
|
||||||
if _, err := os.Stat(target); err == nil {
|
|
||||||
return "", fmt.Errorf("carrier profile %q already exists", target)
|
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return "", fmt.Errorf("stat carrier profile %q: %w", target, err)
|
|
||||||
}
|
|
||||||
temporary, err := os.CreateTemp(dir, "."+result.ProfileID+"-*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create temporary carrier profile: %w", err)
|
|
||||||
}
|
|
||||||
temporaryPath := temporary.Name()
|
|
||||||
removeTemporary := true
|
|
||||||
defer func() {
|
|
||||||
_ = temporary.Close()
|
|
||||||
if removeTemporary {
|
|
||||||
_ = os.Remove(temporaryPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if err := temporary.Chmod(installedProfileFileMode); err != nil {
|
|
||||||
return "", fmt.Errorf("protect temporary carrier profile: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := temporary.Write(result.Document); err != nil {
|
|
||||||
return "", fmt.Errorf("write temporary carrier profile: %w", err)
|
|
||||||
}
|
|
||||||
if err := temporary.Sync(); err != nil {
|
|
||||||
return "", fmt.Errorf("sync temporary carrier profile: %w", err)
|
|
||||||
}
|
|
||||||
if err := temporary.Close(); err != nil {
|
|
||||||
return "", fmt.Errorf("close temporary carrier profile: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(temporaryPath, target); err != nil {
|
|
||||||
return "", fmt.Errorf("install carrier profile %q: %w", target, err)
|
|
||||||
}
|
|
||||||
removeTemporary = false
|
|
||||||
return target, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func carrierBundleRoots(files []*zip.File) []string {
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for _, file := range files {
|
|
||||||
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
|
||||||
if strings.Contains(strings.ToLower(name), "/signatures/") ||
|
|
||||||
!strings.EqualFold(path.Base(name), "carrier.plist") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
root := path.Dir(name)
|
|
||||||
if root == "." || root == "/" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[root] = struct{}{}
|
|
||||||
}
|
|
||||||
result := make([]string, 0, len(seen))
|
|
||||||
for root := range seen {
|
|
||||||
result = append(result, root)
|
|
||||||
}
|
|
||||||
sort.Strings(result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectCarrierBundle(roots []string, wanted string) (string, error) {
|
|
||||||
if len(roots) == 0 {
|
|
||||||
return "", errors.New("IPCC contains no carrier.plist bundle")
|
|
||||||
}
|
|
||||||
wanted = strings.TrimSpace(strings.ReplaceAll(wanted, "\\", "/"))
|
|
||||||
if wanted != "" {
|
|
||||||
for _, root := range roots {
|
|
||||||
base := path.Base(root)
|
|
||||||
if strings.EqualFold(root, wanted) || strings.EqualFold(base, wanted) ||
|
|
||||||
strings.EqualFold(strings.TrimSuffix(base, path.Ext(base)), strings.TrimSuffix(wanted, path.Ext(wanted))) {
|
|
||||||
return root, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("carrier bundle %q not found; choices: %s", wanted, strings.Join(roots, ", "))
|
|
||||||
}
|
|
||||||
if len(roots) != 1 {
|
|
||||||
return "", fmt.Errorf("IPCC contains multiple carrier bundles; select one with --bundle: %s", strings.Join(roots, ", "))
|
|
||||||
}
|
|
||||||
return roots[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCarrierBundlePlists(files []*zip.File, root string) ([]ipccPlist, error) {
|
|
||||||
var primary *zip.File
|
|
||||||
overrides := make([]*zip.File, 0)
|
|
||||||
rootPrefix := strings.TrimSuffix(root, "/") + "/"
|
|
||||||
for _, file := range files {
|
|
||||||
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
|
||||||
if !strings.HasPrefix(name, rootPrefix) || strings.Contains(strings.ToLower(name), "/signatures/") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
base := path.Base(name)
|
|
||||||
switch {
|
|
||||||
case strings.EqualFold(name, rootPrefix+"carrier.plist"):
|
|
||||||
primary = file
|
|
||||||
case strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist"):
|
|
||||||
overrides = append(overrides, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if primary == nil {
|
|
||||||
return nil, fmt.Errorf("bundle %q has no carrier.plist", root)
|
|
||||||
}
|
|
||||||
sort.Slice(overrides, func(i, j int) bool { return overrides[i].Name < overrides[j].Name })
|
|
||||||
selected := append([]*zip.File{primary}, overrides...)
|
|
||||||
result := make([]ipccPlist, 0, len(selected))
|
|
||||||
var total uint64
|
|
||||||
for _, file := range selected {
|
|
||||||
if file.UncompressedSize64 > maxIPCCPlistBytes {
|
|
||||||
return nil, fmt.Errorf("plist %q exceeds %d bytes", file.Name, maxIPCCPlistBytes)
|
|
||||||
}
|
|
||||||
total += file.UncompressedSize64
|
|
||||||
if total > maxIPCCPlistTotalBytes {
|
|
||||||
return nil, fmt.Errorf("selected plists exceed %d uncompressed bytes", maxIPCCPlistTotalBytes)
|
|
||||||
}
|
|
||||||
root, err := decodeIPCCPlist(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decode plist %q: %w", file.Name, err)
|
|
||||||
}
|
|
||||||
result = append(result, ipccPlist{name: file.Name, root: root})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeIPCCPlist(file *zip.File) (map[string]any, error) {
|
|
||||||
reader, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
encoded, err := io.ReadAll(io.LimitReader(reader, maxIPCCPlistBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(encoded) > maxIPCCPlistBytes {
|
|
||||||
return nil, fmt.Errorf("plist exceeds %d bytes", maxIPCCPlistBytes)
|
|
||||||
}
|
|
||||||
decoder := plist.NewDecoder(bytes.NewReader(encoded))
|
|
||||||
var root map[string]any
|
|
||||||
if err := decoder.Decode(&root); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if root == nil {
|
|
||||||
return nil, errors.New("plist root is not a dictionary")
|
|
||||||
}
|
|
||||||
return root, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func importCarrierSelectors(primary map[string]any, plists []ipccPlist, warnings *ipccWarningSet) ([]carrierProfileMatch, []string, error) {
|
|
||||||
supportedSIMs := plistStrings(primary["SupportedSIMs"])
|
|
||||||
supportedPLMNs := normalizedPLMNs(plistStrings(primary["SupportedPLMNs"]))
|
|
||||||
plainPLMNs := make([]string, 0)
|
|
||||||
qualified := make([]carrierProfileMatch, 0)
|
|
||||||
for _, raw := range supportedSIMs {
|
|
||||||
match, constrained, valid := parseAppleSupportedSIM(raw, warnings)
|
|
||||||
if !valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if constrained {
|
|
||||||
qualified = append(qualified, match)
|
|
||||||
} else {
|
|
||||||
plainPLMNs = append(plainPLMNs, match.HomePLMNs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allPLMNs := normalizeIPCCStringList(append(append([]string(nil), plainPLMNs...), supportedPLMNs...), false)
|
|
||||||
matches := qualified
|
|
||||||
if len(matches) == 0 {
|
|
||||||
if len(allPLMNs) == 0 {
|
|
||||||
return nil, nil, errors.New("no supported MCC/MNC selector was found")
|
|
||||||
}
|
|
||||||
match := carrierProfileMatch{HomePLMNs: allPLMNs}
|
|
||||||
iccidPrefixes := collectMatchingICCIDPrefixes(plists)
|
|
||||||
if len(iccidPrefixes) > 0 {
|
|
||||||
match.ICCIDPrefixes = iccidPrefixes
|
|
||||||
warnings.add(
|
|
||||||
"remote_provisioning_iccid_selector",
|
|
||||||
"MatchingICCIDPrefixes was used only because the bundle has no GID/SPN selector; verify that it identifies subscriptions rather than only eSIM provisioning eligibility",
|
|
||||||
"RemoteCardProvisioningSettings.MatchingICCIDPrefixes",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
warnings.add(
|
|
||||||
"broad_plmn_selector",
|
|
||||||
"the generated rule matches a whole home PLMN because the bundle exposes no GID, SPN, or ICCID discriminator",
|
|
||||||
"SupportedSIMs",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
matches = []carrierProfileMatch{match}
|
|
||||||
}
|
|
||||||
matches = deduplicateCarrierMatches(matches)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return nil, nil, errors.New("all SupportedSIMs selectors were unsupported")
|
|
||||||
}
|
|
||||||
if len(allPLMNs) == 0 {
|
|
||||||
for _, match := range matches {
|
|
||||||
allPLMNs = append(allPLMNs, match.HomePLMNs...)
|
|
||||||
}
|
|
||||||
allPLMNs = normalizeIPCCStringList(allPLMNs, false)
|
|
||||||
}
|
|
||||||
return matches, allPLMNs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfileMatch, bool, bool) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
parts := strings.Split(raw, "_")
|
|
||||||
if len(parts) == 0 || !supportedSIMPLMN.MatchString(parts[0]) || canonicalPLMNValue(parts[0]) == "" {
|
|
||||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs value "+strconv.Quote(raw), "SupportedSIMs")
|
|
||||||
return carrierProfileMatch{}, false, false
|
|
||||||
}
|
|
||||||
match := carrierProfileMatch{HomePLMNs: []string{parts[0]}}
|
|
||||||
for _, qualifier := range parts[1:] {
|
|
||||||
name, value, found := strings.Cut(qualifier, "-")
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if !found || value == "" {
|
|
||||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(qualifier), "SupportedSIMs")
|
|
||||||
return carrierProfileMatch{}, false, false
|
|
||||||
}
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
|
||||||
case "GID1":
|
|
||||||
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value))
|
|
||||||
case "GID2":
|
|
||||||
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value))
|
|
||||||
case "ICCID":
|
|
||||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, strings.TrimRight(value, "Ff"))
|
|
||||||
case "SPN":
|
|
||||||
match.SPNs = append(match.SPNs, value)
|
|
||||||
default:
|
|
||||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(name), "SupportedSIMs")
|
|
||||||
return carrierProfileMatch{}, false, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match, len(parts) > 1, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimAppleHexMask(value string) string {
|
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
|
||||||
trimmed := strings.TrimRight(value, "F")
|
|
||||||
if trimmed == "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
|
||||||
values := make([]string, 0)
|
|
||||||
for _, document := range plists {
|
|
||||||
walkPlist(document.root, nil, func(path []string, value any) {
|
|
||||||
if len(path) == 0 || !strings.EqualFold(path[len(path)-1], "MatchingICCIDPrefixes") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, prefix := range plistStrings(value) {
|
|
||||||
prefix = strings.TrimRight(strings.TrimSpace(prefix), "Ff")
|
|
||||||
if len(prefix) >= 5 && decimalString(prefix) {
|
|
||||||
values = append(values, prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return normalizeIPCCStringList(values, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func importCarrierEPDG(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
|
||||||
addresses := make(map[string][]string)
|
|
||||||
for _, document := range plists {
|
|
||||||
for _, ike := range dictionariesForKey(document.root, "IKE") {
|
|
||||||
address := strings.ToLower(strings.TrimSuffix(plistString(ike.value["RemoteAddress"]), "."))
|
|
||||||
if address == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !validEPDGHostname(address) {
|
|
||||||
warnings.add("unsupported_epdg_address", "ignored non-ePDG IKE RemoteAddress "+strconv.Quote(address), document.name+":"+strings.Join(ike.path, "."))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addresses[address] = append(addresses[address], document.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys := sortedMapKeys(addresses)
|
|
||||||
switch len(keys) {
|
|
||||||
case 0:
|
|
||||||
warnings.add("epdg_not_explicit", "no unambiguous ePDG RemoteAddress was found; VoCat will derive the standard 3GPP hostname from the matched PLMN", "TechSettings.IKE.RemoteAddress")
|
|
||||||
case 1:
|
|
||||||
rule.EPDG.Hostname = keys[0]
|
|
||||||
default:
|
|
||||||
warnings.add("conflicting_epdg", "device override plists disagree on ePDG RemoteAddress; no address was imported: "+strings.Join(keys, ", "), "TechSettings.IKE.RemoteAddress")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func importCarrierIKE(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
|
||||||
groups := make(map[int]struct{})
|
|
||||||
eapMethods := make(map[string]struct{})
|
|
||||||
for _, document := range plists {
|
|
||||||
for _, located := range dictionariesForKey(document.root, "IKE") {
|
|
||||||
ike := located.value
|
|
||||||
for _, proposal := range plistDictionaries(ike["Proposals"]) {
|
|
||||||
if group, ok := plistInt(proposal["DHGroup"]); ok {
|
|
||||||
groups[group] = struct{}{}
|
|
||||||
}
|
|
||||||
if method := strings.ToUpper(plistString(proposal["EAPMethod"])); method != "" {
|
|
||||||
eapMethods[method] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if validate, ok := plistBool(ike["ValidateRemoteCertificate"]); ok && !validate {
|
|
||||||
warnings.add("remote_certificate_bypass_ignored", "ValidateRemoteCertificate=false was not imported", document.name+":"+strings.Join(located.path, ".")+".ValidateRemoteCertificate")
|
|
||||||
}
|
|
||||||
if enabled, ok := plistBool(ike["DeadPeerDetectionEnabled"]); ok {
|
|
||||||
if !enabled {
|
|
||||||
warnings.add("disabled_dpd_ignored", "Apple disables DPD for this device family; VoCat keeps its safe liveness defaults", document.name+":"+strings.Join(located.path, ".")+".DeadPeerDetectionEnabled")
|
|
||||||
} else if _, hasInterval := ike["DeadPeerDetectionInterval"]; hasInterval {
|
|
||||||
warnings.add("dpd_override_ignored", "device-specific DPD timing was not imported; VoCat keeps its runtime defaults", document.name+":"+strings.Join(located.path, "."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(groups) > 0 {
|
|
||||||
unknown := make([]string, 0)
|
|
||||||
_, hasModern := groups[14]
|
|
||||||
_, hasLegacy := groups[2]
|
|
||||||
for group := range groups {
|
|
||||||
if group != 2 && group != 14 {
|
|
||||||
unknown = append(unknown, strconv.Itoa(group))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(unknown)
|
|
||||||
switch {
|
|
||||||
case len(unknown) > 0:
|
|
||||||
warnings.add("unsupported_ike_group", "unsupported IKE DH group(s) were not imported: "+strings.Join(unknown, ", "), "TechSettings.IKE.Proposals")
|
|
||||||
case hasModern:
|
|
||||||
rule.IKE.Proposal = IKEProposalModern
|
|
||||||
case hasLegacy:
|
|
||||||
rule.IKE.Proposal = IKEProposalLegacy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for method := range eapMethods {
|
|
||||||
if method != "EAP-AKA" && method != "EAP-AKA'" {
|
|
||||||
warnings.add("unsupported_eap_method", "VoCat does not import Apple EAP method "+strconv.Quote(method), "TechSettings.IKE.Proposals.EAPMethod")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
|
||||||
useIPSec := false
|
|
||||||
for _, document := range plists {
|
|
||||||
for _, signaling := range dictionariesForKey(document.root, "Signaling") {
|
|
||||||
if value, ok := plistBool(signaling.value["UseIPSec"]); ok {
|
|
||||||
if value {
|
|
||||||
useIPSec = true
|
|
||||||
} else {
|
|
||||||
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if useIPSec {
|
|
||||||
// Apple does not describe the negotiated ESP algorithm in a portable
|
|
||||||
// field. Keep VoCat's safe AES-CBC default while recording the intent.
|
|
||||||
rule.IMS.IPSecEncryption = "aes-cbc"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
|
||||||
for _, document := range plists {
|
|
||||||
walkPlist(document.root, nil, func(keyPath []string, value any) {
|
|
||||||
if len(keyPath) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := strings.ToLower(keyPath[len(keyPath)-1])
|
|
||||||
fullPath := document.name + ":" + strings.Join(keyPath, ".")
|
|
||||||
switch {
|
|
||||||
case key == "enablewificallingwithoutentitlement":
|
|
||||||
if enabled, ok := plistBool(value); ok && enabled {
|
|
||||||
warnings.add("entitlement_bypass_ignored", "Wi-Fi Calling entitlement bypass was not imported", fullPath)
|
|
||||||
}
|
|
||||||
case key == "apns":
|
|
||||||
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
|
||||||
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
|
||||||
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
|
||||||
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
|
||||||
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type locatedDictionary struct {
|
|
||||||
path []string
|
|
||||||
value map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func dictionariesForKey(root map[string]any, wanted string) []locatedDictionary {
|
|
||||||
result := make([]locatedDictionary, 0)
|
|
||||||
walkPlist(root, nil, func(keyPath []string, value any) {
|
|
||||||
if len(keyPath) == 0 || !strings.EqualFold(keyPath[len(keyPath)-1], wanted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if dictionary, ok := value.(map[string]any); ok {
|
|
||||||
result = append(result, locatedDictionary{path: append([]string(nil), keyPath...), value: dictionary})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func walkPlist(value any, keyPath []string, visit func([]string, any)) {
|
|
||||||
visit(keyPath, value)
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
keys := make([]string, 0, len(typed))
|
|
||||||
for key := range typed {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, key := range keys {
|
|
||||||
walkPlist(typed[key], appendPath(keyPath, key), visit)
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for index, item := range typed {
|
|
||||||
walkPlist(item, appendPath(keyPath, strconv.Itoa(index)), visit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendPath(base []string, item string) []string {
|
|
||||||
result := make([]string, len(base), len(base)+1)
|
|
||||||
copy(result, base)
|
|
||||||
return append(result, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func plistStrings(value any) []string {
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case string:
|
|
||||||
if strings.TrimSpace(typed) != "" {
|
|
||||||
return []string{strings.TrimSpace(typed)}
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
result := make([]string, 0, len(typed))
|
|
||||||
for _, item := range typed {
|
|
||||||
if value := plistString(item); value != "" {
|
|
||||||
result = append(result, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case []string:
|
|
||||||
return normalizeIPCCStringList(typed, false)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeIPCCStringList(values []string, lower bool) []string {
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
seen := make(map[string]struct{}, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if lower {
|
|
||||||
value = strings.ToLower(value)
|
|
||||||
}
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, duplicate := seen[value]; duplicate {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
result = append(result, value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func plistDictionaries(value any) []map[string]any {
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
return []map[string]any{typed}
|
|
||||||
case []any:
|
|
||||||
result := make([]map[string]any, 0, len(typed))
|
|
||||||
for _, item := range typed {
|
|
||||||
if dictionary, ok := item.(map[string]any); ok {
|
|
||||||
result = append(result, dictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func plistString(value any) string {
|
|
||||||
if text, ok := value.(string); ok {
|
|
||||||
return strings.TrimSpace(text)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func plistBool(value any) (bool, bool) {
|
|
||||||
result, ok := value.(bool)
|
|
||||||
return result, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func plistInt(value any) (int, bool) {
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case int:
|
|
||||||
return typed, true
|
|
||||||
case int64:
|
|
||||||
return int(typed), int64(int(typed)) == typed
|
|
||||||
case uint64:
|
|
||||||
return int(typed), uint64(int(typed)) == typed
|
|
||||||
case float64:
|
|
||||||
return int(typed), float64(int(typed)) == typed
|
|
||||||
default:
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizedPLMNs(values []string) []string {
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if supportedSIMPLMN.MatchString(value) && canonicalPLMNValue(value) != "" {
|
|
||||||
result = append(result, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return normalizeIPCCStringList(result, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deduplicateCarrierMatches(matches []carrierProfileMatch) []carrierProfileMatch {
|
|
||||||
result := make([]carrierProfileMatch, 0, len(matches))
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for _, match := range matches {
|
|
||||||
encoded, _ := json.Marshal(match)
|
|
||||||
key := string(encoded)
|
|
||||||
if _, duplicate := seen[key]; duplicate {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
result = append(result, match)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func statusBarCarrierName(root map[string]any) string {
|
|
||||||
for _, item := range plistDictionaries(root["StatusBarImages"]) {
|
|
||||||
if name := firstNonempty(plistString(item["CarrierName"]), plistString(item["StatusBarCarrierName"])); name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func generatedIPCCProfileID(carrierName string, plmns []string) string {
|
|
||||||
base := slugCarrierProfileID(carrierName)
|
|
||||||
if base == "" {
|
|
||||||
base = "carrier"
|
|
||||||
}
|
|
||||||
if len(plmns) > 0 {
|
|
||||||
base += "-" + plmns[0]
|
|
||||||
}
|
|
||||||
base = "ipcc-" + base
|
|
||||||
if len(base) > 64 {
|
|
||||||
base = strings.TrimRight(base[:64], "-._")
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
func slugCarrierProfileID(value string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
separator := false
|
|
||||||
for _, item := range strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
switch {
|
|
||||||
case item >= 'a' && item <= 'z', item >= '0' && item <= '9':
|
|
||||||
if separator && result.Len() > 0 {
|
|
||||||
result.WriteByte('-')
|
|
||||||
}
|
|
||||||
result.WriteRune(item)
|
|
||||||
separator = false
|
|
||||||
case unicode.IsSpace(item), item == '-', item == '_', item == '.':
|
|
||||||
separator = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Trim(result.String(), "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validInstalledProfileID(value string) bool {
|
|
||||||
if len(value) < 1 || len(value) > 64 || !asciiLowerOrDigit(rune(value[0])) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, item := range value {
|
|
||||||
if asciiLowerOrDigit(item) || item == '-' || item == '_' || item == '.' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func asciiLowerOrDigit(item rune) bool {
|
|
||||||
return item >= 'a' && item <= 'z' || item >= '0' && item <= '9'
|
|
||||||
}
|
|
||||||
|
|
||||||
func validEPDGHostname(value string) bool {
|
|
||||||
if len(value) < 4 || len(value) > 253 || !strings.Contains(strings.ToLower(value), "epdg") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, label := range strings.Split(value, ".") {
|
|
||||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, item := range label {
|
|
||||||
if item >= 'a' && item <= 'z' || item >= '0' && item <= '9' || item == '-' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func consensusPositiveInt(values []int) (int, bool) {
|
|
||||||
if len(values) == 0 || values[0] <= 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
for _, value := range values[1:] {
|
|
||||||
if value != values[0] {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values[0], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedMapKeys[T any](values map[string]T) []string {
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
for key := range values {
|
|
||||||
result = append(result, key)
|
|
||||||
}
|
|
||||||
sort.Strings(result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonempty(values ...string) string {
|
|
||||||
for _, value := range values {
|
|
||||||
if value = strings.TrimSpace(value); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package vowifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"howett.net/plist"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testIPCCPlist struct {
|
|
||||||
value map[string]any
|
|
||||||
format int
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
|
||||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
|
||||||
"Payload/O2_Giffgaff_UK.bundle/carrier.plist": {
|
|
||||||
format: plist.XMLFormat,
|
|
||||||
value: map[string]any{
|
|
||||||
"CarrierName": "giffgaff",
|
|
||||||
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
|
||||||
"SupportedPLMNs": []any{"23410"},
|
|
||||||
"apns": []any{map[string]any{"apn": "giffgaff.com"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Payload/O2_Giffgaff_UK.bundle/overrides_D1.plist": {
|
|
||||||
format: plist.BinaryFormat,
|
|
||||||
value: map[string]any{
|
|
||||||
"TechSettings": map[string]any{
|
|
||||||
"IKE": map[string]any{
|
|
||||||
"RemoteAddress": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org",
|
|
||||||
"ValidateRemoteCertificate": false,
|
|
||||||
"DeadPeerDetectionEnabled": false,
|
|
||||||
"Proposals": []any{map[string]any{
|
|
||||||
"DHGroup": 14, "EAPMethod": "EAP-AKA",
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"IMSConfig": map[string]any{
|
|
||||||
"EnableWiFiCallingWithoutEntitlement": true,
|
|
||||||
"Signaling": map[string]any{"UseIPSec": true},
|
|
||||||
"Media": map[string]any{"SupportPCMA": false},
|
|
||||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result.CarrierName != "giffgaff" || result.ProfileID != "ipcc-giffgaff-23410" || result.SourceSHA256 == "" {
|
|
||||||
t.Fatalf("import metadata = %#v", result)
|
|
||||||
}
|
|
||||||
var document carrierProfileDocument
|
|
||||||
if err := json.Unmarshal(result.Document, &document); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if document.Version != CarrierProfileSchemaVersion || len(document.Profiles) != 1 {
|
|
||||||
t.Fatalf("document = %#v", document)
|
|
||||||
}
|
|
||||||
rule := document.Profiles[0]
|
|
||||||
if rule.Match.HomePLMNs[0] != "23410" || rule.Match.GID1Prefixes[0] != "508" {
|
|
||||||
t.Fatalf("converted selector = %#v", rule.Match)
|
|
||||||
}
|
|
||||||
if rule.EPDG.Hostname != "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org" || rule.IKE.Proposal != IKEProposalModern {
|
|
||||||
t.Fatalf("converted IKE profile = %#v", rule)
|
|
||||||
}
|
|
||||||
if rule.IMS.IPSecEncryption != "aes-cbc" {
|
|
||||||
t.Fatalf("converted IMS profile = %#v", rule.IMS)
|
|
||||||
}
|
|
||||||
for _, code := range []string{
|
|
||||||
"remote_certificate_bypass_ignored",
|
|
||||||
"disabled_dpd_ignored",
|
|
||||||
"entitlement_bypass_ignored",
|
|
||||||
"apn_settings_ignored",
|
|
||||||
"device_media_overrides_ignored",
|
|
||||||
"emergency_settings_ignored",
|
|
||||||
} {
|
|
||||||
if !hasIPCCWarning(result.Warnings, code) {
|
|
||||||
t.Errorf("missing warning %q: %#v", code, result.Warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportCarrierIPCCRejectsAmbiguousBundleAndConflictingEPDG(t *testing.T) {
|
|
||||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
|
||||||
"Payload/One.bundle/carrier.plist": {
|
|
||||||
format: plist.XMLFormat,
|
|
||||||
value: map[string]any{"CarrierName": "One", "SupportedSIMs": []any{"99901"}},
|
|
||||||
},
|
|
||||||
"Payload/One.bundle/overrides_A.plist": {
|
|
||||||
format: plist.XMLFormat,
|
|
||||||
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.one.example"}}},
|
|
||||||
},
|
|
||||||
"Payload/One.bundle/overrides_B.plist": {
|
|
||||||
format: plist.BinaryFormat,
|
|
||||||
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.two.example"}}},
|
|
||||||
},
|
|
||||||
"Payload/Two.bundle/carrier.plist": {
|
|
||||||
format: plist.BinaryFormat,
|
|
||||||
value: map[string]any{"CarrierName": "Two", "SupportedSIMs": []any{"99902"}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{}); err == nil {
|
|
||||||
t.Fatal("multi-bundle IPCC imported without --bundle")
|
|
||||||
}
|
|
||||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{Bundle: "One"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var document carrierProfileDocument
|
|
||||||
if err := json.Unmarshal(result.Document, &document); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if document.Profiles[0].EPDG.Hostname != "" || !hasIPCCWarning(result.Warnings, "conflicting_epdg") {
|
|
||||||
t.Fatalf("conflicting ePDG was not quarantined: %#v, %#v", document.Profiles[0], result.Warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInstallCarrierIPCCResultLoadsExternalProfileAtEqualSpecificity(t *testing.T) {
|
|
||||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
|
||||||
"Payload/Test.bundle/carrier.plist": {
|
|
||||||
format: plist.BinaryFormat,
|
|
||||||
value: map[string]any{
|
|
||||||
"CarrierName": "Installed Test",
|
|
||||||
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
|
||||||
"SupportedPLMNs": []any{"23410"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{ProfileID: "installed-giffgaff-test"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
|
||||||
emptyDir := t.TempDir()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if err := LoadCarrierProfileDirectory(emptyDir); err != nil {
|
|
||||||
t.Errorf("clear external profiles: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
target, err := InstallCarrierIPCCResult(result, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if filepath.Base(target) != "installed-giffgaff-test.json" {
|
|
||||||
t.Fatalf("installed path = %q", target)
|
|
||||||
}
|
|
||||||
if _, err := InstallCarrierIPCCResult(result, dir); err == nil {
|
|
||||||
t.Fatal("second install overwrote an existing profile")
|
|
||||||
}
|
|
||||||
if err := LoadCarrierProfileDirectory(dir); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"})
|
|
||||||
if profile.ID != "installed-giffgaff-test" {
|
|
||||||
t.Fatalf("installed equal-specificity profile did not override builtin: %#v", profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTestIPCC(t *testing.T, files map[string]testIPCCPlist) string {
|
|
||||||
t.Helper()
|
|
||||||
archivePath := filepath.Join(t.TempDir(), "carrier.ipcc")
|
|
||||||
file, err := os.Create(archivePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
archive := zip.NewWriter(file)
|
|
||||||
for name, item := range files {
|
|
||||||
var encoded bytes.Buffer
|
|
||||||
if err := plist.NewEncoderForFormat(&encoded, item.format).Encode(item.value); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
entry, err := archive.Create(name)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := archive.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return archivePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasIPCCWarning(warnings []IPCCImportWarning, code string) bool {
|
|
||||||
for _, warning := range warnings {
|
|
||||||
if warning.Code == code {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user