mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-17 05:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eee89a92a | ||
|
|
ae3a2a6eea | ||
|
|
505ee1eac0 | ||
|
|
b19ae2240a | ||
|
|
adf7de6d29 | ||
|
|
ffa0fd23b8 | ||
|
|
f014628048 | ||
|
|
68813198f9 | ||
|
|
5774b196d2 | ||
|
|
5604813254 | ||
|
|
38b4264133 | ||
|
|
df9dbfee74 | ||
|
|
8dc6d4f620 | ||
|
|
70bdb88e4c | ||
|
|
091d12cd30 | ||
|
|
c4c6a6c181 | ||
|
|
540a967f96 |
@@ -0,0 +1,75 @@
|
||||
name: Issue Report
|
||||
description: Report a bug or problem with VoCat. Please answer every question below.
|
||||
title: "[Issue]: "
|
||||
labels: ["triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to open an issue. Please fill in all the fields
|
||||
below so we can triage and handle your report as quickly as possible.
|
||||
|
||||
- type: checkboxes
|
||||
id: searched-existing
|
||||
attributes:
|
||||
label: Existing Issues
|
||||
description: Have you searched through past Issues (both open and closed) to check whether this problem, or a similar one, has already been reported?
|
||||
options:
|
||||
- label: I have searched past Issues and found no similar report.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the problem you encountered and what you expected to happen instead.
|
||||
placeholder: A clear and concise description of the issue...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Suggested Priority
|
||||
description: In your opinion, what priority should this issue be handled with?
|
||||
options:
|
||||
- Low
|
||||
- Medium
|
||||
- High
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected Area
|
||||
description: Do you think this is a frontend or backend error?
|
||||
options:
|
||||
- Frontend
|
||||
- Backend
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: abuse-mcc-acknowledgement
|
||||
attributes:
|
||||
label: Acknowledgement
|
||||
description: Please read and confirm the following before submitting.
|
||||
options:
|
||||
- label: >-
|
||||
I understand that this repository will not modify any feature code on
|
||||
behalf of abusers in order to enable abuse, and that this service must
|
||||
not be used in regions with MCC=460; any issues
|
||||
arising from such use will not be resolved.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error-messages
|
||||
attributes:
|
||||
label: Error Messages
|
||||
description: Did you encounter any error messages? If so, please paste them here.
|
||||
placeholder: Paste any relevant error output or logs...
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
@@ -2,11 +2,18 @@ name: Pull request size limit
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches:
|
||||
- master
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: pr-size-limit-${{ github.event.pull_request.number }}
|
||||
@@ -14,67 +21,262 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
enforce-size-limit:
|
||||
# 保持这个名字不变,这样你 Ruleset 里的 Required Check 不需要修改
|
||||
name: Enforce 5,000-line limit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
MAX_CHANGED_LINES: "5000"
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
steps:
|
||||
- name: Reject oversized pull request
|
||||
- name: Checkout trusted base repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check conflicts and pull request size
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
api_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"
|
||||
response="$({
|
||||
curl --fail-with-body --silent --show-error \
|
||||
echo "Checking PR #${PR_NUMBER}"
|
||||
echo "Base branch: ${BASE_REF}"
|
||||
|
||||
############################################################
|
||||
# Helper: comment on and close rejected PR
|
||||
############################################################
|
||||
|
||||
reject_pr() {
|
||||
local message="$1"
|
||||
|
||||
echo "::error::${message}"
|
||||
|
||||
COMMENT_PAYLOAD="$(
|
||||
jq -nc \
|
||||
--arg body "${message}" \
|
||||
'{body: $body}'
|
||||
)"
|
||||
|
||||
echo "Posting rejection comment..."
|
||||
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--request POST \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "Authorization: Bearer ${GH_TOKEN}" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${api_url}"
|
||||
})"
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--data "${COMMENT_PAYLOAD}" \
|
||||
>/dev/null
|
||||
|
||||
echo "Closing PR #${PR_NUMBER}..."
|
||||
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--request PATCH \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "Authorization: Bearer ${GH_TOKEN}" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
--data '{"state":"closed"}' \
|
||||
>/dev/null
|
||||
|
||||
additions="$(jq -r '.additions' <<<"${response}")"
|
||||
deletions="$(jq -r '.deletions' <<<"${response}")"
|
||||
if [[ ! "${additions}" =~ ^[0-9]+$ || ! "${deletions}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Unable to read pull request line statistics." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
############################################################
|
||||
# Fetch target branch and PR HEAD
|
||||
############################################################
|
||||
|
||||
echo "Fetching base branch and PR head..."
|
||||
|
||||
git fetch --no-tags --force origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/base-pr-check" \
|
||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}"
|
||||
|
||||
BASE_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/base-pr-check
|
||||
)"
|
||||
|
||||
PR_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/pr-${PR_NUMBER}
|
||||
)"
|
||||
|
||||
echo "Base commit: ${BASE_COMMIT}"
|
||||
echo "PR commit: ${PR_COMMIT}"
|
||||
|
||||
############################################################
|
||||
# STEP 1: Reject PRs with merge conflicts
|
||||
############################################################
|
||||
|
||||
echo
|
||||
echo "Checking for merge conflicts..."
|
||||
|
||||
set +e
|
||||
|
||||
git merge-tree \
|
||||
--write-tree \
|
||||
--quiet \
|
||||
"${BASE_COMMIT}" \
|
||||
"${PR_COMMIT}"
|
||||
|
||||
MERGE_STATUS=$?
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "${MERGE_STATUS}" -eq 1 ]]; then
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflicts: ❌ Detected"
|
||||
echo "- Result: Rejected"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reject_pr "This pull request has merge conflicts with the current master branch and cannot be accepted. Please update your branch with the latest master, resolve all merge conflicts locally, and submit a conflict-free pull request."
|
||||
|
||||
elif [[ "${MERGE_STATUS}" -ne 0 ]]; then
|
||||
|
||||
echo "::error::Unable to determine whether the pull request can be merged."
|
||||
echo "git merge-tree returned status ${MERGE_STATUS}."
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflict check: ⚠️ Error"
|
||||
echo "- Result: Check failed"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
changed_lines=$((additions + deletions))
|
||||
echo "No merge conflicts detected."
|
||||
|
||||
############################################################
|
||||
# STEP 2: Determine merge base
|
||||
############################################################
|
||||
|
||||
if ! MERGE_BASE="$(
|
||||
git merge-base "${BASE_COMMIT}" "${PR_COMMIT}"
|
||||
)"; then
|
||||
|
||||
echo "::error::Unable to determine merge base."
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflicts: ✅ None"
|
||||
echo "- Diff calculation: ⚠️ Failed"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
echo "Merge base: ${MERGE_BASE}"
|
||||
|
||||
############################################################
|
||||
# STEP 3: Calculate actual PR changed lines
|
||||
############################################################
|
||||
|
||||
NUMSTAT_FILE="$(mktemp)"
|
||||
|
||||
git diff \
|
||||
--no-ext-diff \
|
||||
--no-textconv \
|
||||
--numstat \
|
||||
"${MERGE_BASE}" \
|
||||
"${PR_COMMIT}" \
|
||||
> "${NUMSTAT_FILE}"
|
||||
|
||||
ADDITIONS="$(
|
||||
awk '
|
||||
$1 ~ /^[0-9]+$/ {
|
||||
total += $1
|
||||
}
|
||||
|
||||
END {
|
||||
print total + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
DELETIONS="$(
|
||||
awk '
|
||||
$2 ~ /^[0-9]+$/ {
|
||||
total += $2
|
||||
}
|
||||
|
||||
END {
|
||||
print total + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
CHANGED_FILES="$(
|
||||
awk '
|
||||
END {
|
||||
print NR + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
CHANGED_LINES=$((ADDITIONS + DELETIONS))
|
||||
|
||||
############################################################
|
||||
# Action summary
|
||||
############################################################
|
||||
|
||||
{
|
||||
echo "### Pull request size"
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Additions: ${additions}"
|
||||
echo "- Deletions: ${deletions}"
|
||||
echo "- Total changed lines: ${changed_lines}"
|
||||
echo "- Limit: ${MAX_CHANGED_LINES}"
|
||||
} >>"${GITHUB_STEP_SUMMARY}"
|
||||
echo "- Merge conflicts: ✅ None"
|
||||
echo "- Changed files: ${CHANGED_FILES}"
|
||||
echo "- Additions: ${ADDITIONS}"
|
||||
echo "- Deletions: ${DELETIONS}"
|
||||
echo "- Total changed lines: ${CHANGED_LINES}"
|
||||
echo "- Maximum allowed: ${MAX_CHANGED_LINES}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
echo
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
echo "Additions: ${ADDITIONS}"
|
||||
echo "Deletions: ${DELETIONS}"
|
||||
echo "Total changed lines: ${CHANGED_LINES}"
|
||||
echo "Limit: ${MAX_CHANGED_LINES}"
|
||||
|
||||
############################################################
|
||||
# STEP 4: Reject oversized PRs
|
||||
############################################################
|
||||
|
||||
if (( CHANGED_LINES > MAX_CHANGED_LINES )); then
|
||||
|
||||
reject_pr "This pull request changes ${CHANGED_LINES} lines (${ADDITIONS} additions + ${DELETIONS} deletions) across ${CHANGED_FILES} files, exceeding the repository limit of ${MAX_CHANGED_LINES} changed lines. It has been closed automatically. Please split the changes into smaller pull requests."
|
||||
|
||||
if (( changed_lines <= MAX_CHANGED_LINES )); then
|
||||
echo "Pull request is within the ${MAX_CHANGED_LINES}-line limit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request PATCH \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "Authorization: Bearer ${GH_TOKEN}" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${api_url}" \
|
||||
--data '{"state":"closed"}' >/dev/null
|
||||
############################################################
|
||||
# PASS
|
||||
############################################################
|
||||
|
||||
message="This pull request changes ${changed_lines} lines (${additions} additions + ${deletions} deletions), exceeding the repository limit of ${MAX_CHANGED_LINES} changed lines. It has been closed automatically. Please split the changes into smaller pull requests."
|
||||
comment_payload="$(jq -nc --arg body "${message}" '{body: $body}')"
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request POST \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "Authorization: Bearer ${GH_TOKEN}" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--data "${comment_payload}" >/dev/null
|
||||
echo
|
||||
echo "Pull request passed all policy checks."
|
||||
echo "No merge conflicts."
|
||||
echo "Changed lines: ${CHANGED_LINES}/${MAX_CHANGED_LINES}."
|
||||
|
||||
echo "::error::Pull request changes ${changed_lines} lines; the maximum is ${MAX_CHANGED_LINES}."
|
||||
exit 1
|
||||
{
|
||||
echo
|
||||
echo "### Result"
|
||||
echo
|
||||
echo "✅ Pull request passed."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 iniwex5
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
VoCat uses the following Go module for native Qualcomm QMI support:
|
||||
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
||||
Distribution: https://proxy.golang.org/github.com/iniwex5/quectel-qmi-go/@v/v0.6.0.zip
|
||||
Documentation and license metadata: https://pkg.go.dev/github.com/iniwex5/[email protected]
|
||||
License: MIT
|
||||
Copyright: Copyright (c) 2026 iniwex5
|
||||
|
||||
The full MIT license text is included in:
|
||||
LICENSES/quectel-qmi-go-MIT.txt
|
||||
@@ -23,14 +23,14 @@ func runBootstrapAdmin(args []string) error {
|
||||
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
|
||||
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
|
||||
}
|
||||
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
|
||||
if len(password) < 6 || len(password) > 1024 {
|
||||
return errors.New("bootstrap password must contain between 6 and 1024 characters")
|
||||
if password == "" {
|
||||
return errors.New("bootstrap password cannot be empty")
|
||||
}
|
||||
adminUsername := strings.TrimSpace(*username)
|
||||
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
||||
|
||||
+34
-15
@@ -27,6 +27,7 @@ import (
|
||||
"vocat/internal/extensions"
|
||||
"vocat/internal/httpsmode"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
"vocat/internal/server"
|
||||
"vocat/internal/store"
|
||||
@@ -191,7 +192,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}
|
||||
|
||||
cardReaders := pcsc.New()
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device manager: %w", err)
|
||||
}
|
||||
@@ -580,6 +581,13 @@ func configureVoWiFiRuntime(
|
||||
if err != nil {
|
||||
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) {
|
||||
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
|
||||
if resolveErr != nil {
|
||||
@@ -605,8 +613,10 @@ func configureVoWiFiRuntime(
|
||||
adapter := vowifiDeviceAdapter(ec20Adapter)
|
||||
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
adapter = pcscAdapter
|
||||
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
||||
adapter = nativeQMIAdapter
|
||||
}
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -705,26 +715,25 @@ func newVoWiFiOrchestrator(
|
||||
deviceConfig store.Device,
|
||||
database *store.Store,
|
||||
adapter vowifiDeviceAdapter,
|
||||
logger *slog.Logger,
|
||||
) (*vowifi.Orchestrator, error) {
|
||||
apn := deviceConfig.APN
|
||||
if apn == "" {
|
||||
apn = "ims"
|
||||
}
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||
}
|
||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||
// The userspace SWu data plane carries protected P-CSCF signalling over
|
||||
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
|
||||
Transport: "tcp",
|
||||
TransportByPLMN: map[string]string{
|
||||
"23410": "udp",
|
||||
"234010": "udp",
|
||||
},
|
||||
// Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes
|
||||
// this service-centre number for manual SMS setup.
|
||||
SMSCenter: "+447785016005",
|
||||
Logger: logger,
|
||||
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||
// alternate transport only if no SIP response was observed.
|
||||
Transport: "tcp",
|
||||
AutoTransportFallback: true,
|
||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"transport": "ims",
|
||||
@@ -836,9 +845,9 @@ func provisionDiscoveredDevices(
|
||||
}
|
||||
for _, discovered := range manager.List() {
|
||||
candidate := discovered.Candidate
|
||||
deviceType := provisionedDeviceType(candidate)
|
||||
backend := "at"
|
||||
control := candidate.ATPort.OpenPath()
|
||||
deviceType := store.DeviceTypePCIeEC20EC25
|
||||
esimTransport := backend
|
||||
if candidate.QMIControl != "" {
|
||||
backend = "qmi"
|
||||
@@ -855,6 +864,7 @@ func provisionDiscoveredDevices(
|
||||
if name == "" || strings.EqualFold(name, "Android") {
|
||||
name = "Quectel EC20 / EC25"
|
||||
}
|
||||
supportsSMS := deviceType != store.DeviceTypeWiFi410
|
||||
if err := database.UpsertDevice(ctx, store.Device{
|
||||
ID: discovered.ID,
|
||||
Name: name,
|
||||
@@ -871,7 +881,7 @@ func provisionDiscoveredDevices(
|
||||
DeviceBackend: backend,
|
||||
ESIMTransport: esimTransport,
|
||||
NetworkEnabled: false,
|
||||
SMSEnabled: true,
|
||||
SMSEnabled: supportsSMS,
|
||||
VoWiFiEnabled: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -880,6 +890,15 @@ func provisionDiscoveredDevices(
|
||||
return nil
|
||||
}
|
||||
|
||||
func provisionedDeviceType(candidate modem.Candidate) string {
|
||||
controlName := filepath.Base(filepath.Clean(candidate.QMIControl))
|
||||
if candidate.HardwareKind == "wwan" &&
|
||||
strings.HasPrefix(controlName, "wwan") && strings.Contains(controlName, "qmi") {
|
||||
return store.DeviceTypeWiFi410
|
||||
}
|
||||
return store.DeviceTypePCIeEC20EC25
|
||||
}
|
||||
|
||||
// persistLogsToStore subscribes to the live log hub and durably appends every
|
||||
// entry to the log_events table, so runtime logs survive restarts and can be
|
||||
// pruned by the configured retention policy.
|
||||
|
||||
@@ -217,3 +217,24 @@ func TestEnforceCardRegionIgnoresUnknownOrNotReadySIM(t *testing.T) {
|
||||
t.Fatalf("expected no card policies, got %d", len(policies))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionedDeviceTypeRecognizesNativeWWAN(t *testing.T) {
|
||||
native := modem.Candidate{
|
||||
HardwareKind: "wwan",
|
||||
USBPath: "/sys/devices/pci0000:00/0000:00:00.0/wwan/wwan0",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
ATPort: modem.Port{Path: "/dev/wwan0at0"},
|
||||
}
|
||||
if got := provisionedDeviceType(native); got != store.DeviceTypeWiFi410 {
|
||||
t.Fatalf("native WWAN type = %q, want %q", got, store.DeviceTypeWiFi410)
|
||||
}
|
||||
|
||||
usb := modem.Candidate{
|
||||
USBPath: "/sys/bus/usb/devices/1-6",
|
||||
QMIControl: "/dev/cdc-wdm0",
|
||||
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
|
||||
}
|
||||
if got := provisionedDeviceType(usb); got != store.DeviceTypePCIeEC20EC25 {
|
||||
t.Fatalf("USB modem type = %q, want %q", got, store.DeviceTypePCIeEC20EC25)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -198,8 +198,7 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
ctx := context.Background()
|
||||
|
||||
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
@@ -215,7 +214,6 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
|
||||
fmt.Print(m.newUsername(admin.Username))
|
||||
username, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
@@ -525,6 +523,7 @@ func menuUpdate(m *menu, logger *slog.Logger) error {
|
||||
}
|
||||
fmt.Println(m.updateChecking())
|
||||
if err := update.Run(logger, []string{"--repo", repo}); err != nil {
|
||||
logger.Error("menu update failed", "error", err)
|
||||
return fmt.Errorf("%w: %v", errUpdateFailed, err)
|
||||
}
|
||||
return nil
|
||||
@@ -596,7 +595,7 @@ func (m *menu) msg(key string) string {
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "},
|
||||
"new_pw": {"新密码 (至少 6 位): ", "New password (min 6 chars): "},
|
||||
"new_pw": {"新密码: ", "New password: "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
|
||||
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
||||
@@ -688,10 +687,11 @@ func (m *menu) errorPrefix(err error) string {
|
||||
}
|
||||
return "重启失败。"
|
||||
case errors.Is(err, errUpdateFailed):
|
||||
detail := strings.TrimPrefix(err.Error(), errUpdateFailed.Error()+": ")
|
||||
if m.lang == "en" {
|
||||
return "Update failed."
|
||||
return "Update failed: " + detail
|
||||
}
|
||||
return "更新失败。"
|
||||
return "更新失败: " + detail
|
||||
case errors.Is(err, errMenuConfig):
|
||||
if m.lang == "en" {
|
||||
return "Failed to load configuration."
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
@@ -18,6 +19,8 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/warthog618/sms v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -2,6 +2,7 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -10,16 +11,25 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/warthog618/sms v0.3.0 h1:LYAb5ngmu2qjNExgji3B7xi2tIZ9+DsuE9pC5xs4wwc=
|
||||
github.com/warthog618/sms v0.3.0/go.mod h1:+bYZGeBxu003sxD5xhzsrIPBAjPBzTABsRTwSpd7ld4=
|
||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
@@ -37,6 +47,10 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
|
||||
+48
-12
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -20,8 +21,13 @@ var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidCSRF = errors.New("invalid csrf token")
|
||||
ErrEmptyPassword = errors.New("password cannot be empty")
|
||||
)
|
||||
|
||||
const bcryptPasswordLimit = 72
|
||||
|
||||
var longPasswordHashPrefix = []byte("$vocat-sha256$")
|
||||
|
||||
type Options struct {
|
||||
SessionTTL time.Duration
|
||||
BcryptCost int
|
||||
@@ -85,14 +91,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
|
||||
current, err := s.store.CurrentAdmin(ctx)
|
||||
if err == nil &&
|
||||
current.Username == username &&
|
||||
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil {
|
||||
comparePassword(current.PasswordHash, password) == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("auth: read configured admin: %w", err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
|
||||
passwordHash, err := hashPassword(password, s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash admin password: %w", err)
|
||||
}
|
||||
@@ -127,8 +133,8 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
|
||||
if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") {
|
||||
return errors.New("administrator username must contain between 1 and 64 characters without control whitespace")
|
||||
}
|
||||
if len(password) < 6 || len(password) > 1024 {
|
||||
return errors.New("administrator password must contain between 6 and 1024 characters")
|
||||
if password == "" {
|
||||
return ErrEmptyPassword
|
||||
}
|
||||
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
||||
return fmt.Errorf("auth: reset administrator credentials: %w", err)
|
||||
@@ -139,13 +145,13 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
|
||||
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
|
||||
_ = comparePassword(s.dummyHash, password)
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil {
|
||||
if comparePassword(admin.PasswordHash, password) != nil {
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -284,24 +290,24 @@ func (s *Service) ChangePassword(
|
||||
currentPassword string,
|
||||
newPassword string,
|
||||
) error {
|
||||
if len(newPassword) < 6 || len(newPassword) > 1024 {
|
||||
return errors.New("new password must contain between 6 and 1024 characters")
|
||||
if newPassword == "" {
|
||||
return ErrEmptyPassword
|
||||
}
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword))
|
||||
_ = comparePassword(s.dummyHash, currentPassword)
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil {
|
||||
if comparePassword(admin.PasswordHash, currentPassword) != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil {
|
||||
if comparePassword(admin.PasswordHash, newPassword) == nil {
|
||||
return errors.New("new password must differ from the current password")
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost)
|
||||
passwordHash, err := hashPassword(newPassword, s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash new password: %w", err)
|
||||
}
|
||||
@@ -311,6 +317,36 @@ func (s *Service) ChangePassword(
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword keeps ordinary bcrypt hashes compatible with existing
|
||||
// installations. bcrypt rejects inputs longer than 72 bytes, so only longer
|
||||
// passwords use a tagged SHA-256 pre-hash before bcrypt.
|
||||
func hashPassword(password string, cost int) ([]byte, error) {
|
||||
material := []byte(password)
|
||||
longPassword := len(material) > bcryptPasswordLimit
|
||||
if longPassword {
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword(material, cost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !longPassword {
|
||||
return passwordHash, nil
|
||||
}
|
||||
return append(append([]byte(nil), longPasswordHashPrefix...), passwordHash...), nil
|
||||
}
|
||||
|
||||
func comparePassword(passwordHash []byte, password string) error {
|
||||
material := []byte(password)
|
||||
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
passwordHash = passwordHash[len(longPasswordHashPrefix):]
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword(passwordHash, material)
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -128,7 +129,7 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
|
||||
}{
|
||||
{name: "empty username", password: "replacement-password"},
|
||||
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
|
||||
{name: "short password", username: "admin", password: "short"},
|
||||
{name: "empty password", username: "admin", password: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
|
||||
@@ -138,6 +139,32 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, password := range []string{"1", strings.Repeat("x", 256)} {
|
||||
service := newTestService(t)
|
||||
if err := service.ResetAdminCredentials(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("ResetAdminCredentials(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, password := range []string{"1", strings.Repeat("long-password-", 32)} {
|
||||
service := newTestService(t)
|
||||
if err := service.ChangePassword(ctx, "admin", "correct-password", password); err != nil {
|
||||
t.Fatalf("ChangePassword(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newTestService(t)
|
||||
|
||||
@@ -3,6 +3,7 @@ package device
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) {
|
||||
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 database carrierDatabase
|
||||
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
||||
@@ -104,7 +124,7 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
|
||||
// several customer-facing carriers authenticate through the same home PLMN.
|
||||
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
if !decimalDigits(imsi, 5, 20) {
|
||||
if !decimalDigits(imsi, 5, 20) || IsPlaceholderIMSI(imsi) {
|
||||
return "", "", "", false
|
||||
}
|
||||
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
|
||||
|
||||
@@ -277,6 +277,10 @@ func (manager *Manager) SetFlight(
|
||||
if manager.candidateFor(state).HardwareKind == "pcsc" {
|
||||
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
if result, handled, err := manager.setNativeQMIFlight(ctx, id, state, enabled); handled {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return result, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
|
||||
@@ -2,9 +2,209 @@ package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
type fakeQMIRadioSession struct {
|
||||
mode qmi.OperatingMode
|
||||
getModes []qmi.OperatingMode
|
||||
setModes []qmi.OperatingMode
|
||||
getErr error
|
||||
setErr error
|
||||
closeCount int
|
||||
iccid string
|
||||
iccidErr error
|
||||
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) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 0")},
|
||||
|
||||
@@ -99,6 +99,31 @@ func (manager *Manager) SetNetwork(
|
||||
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
|
||||
}
|
||||
// OpenStick's native WWAN path must drive registration through QMI NAS.
|
||||
// AT+COPS only updates the legacy AT facade on this firmware and can leave
|
||||
// NAS in not-registered-searching, which then makes qmi-network report a
|
||||
// generic-no-service call failure.
|
||||
if request.Enabled && isNativeQMICandidate(candidate) {
|
||||
registrationContext, cancel := context.WithTimeout(ctx, manager.scanTimeout)
|
||||
registrationSession, openErr := manager.openNativeQMIRegistration(registrationContext, candidate)
|
||||
if openErr != nil {
|
||||
cancel()
|
||||
manager.setResult(id, state, nil, openErr)
|
||||
return NetworkResult{}, fmt.Errorf("prepare native QMI registration: %w", openErr)
|
||||
}
|
||||
registrationErr := ensureNativeQMIRegistration(
|
||||
registrationContext,
|
||||
registrationSession,
|
||||
qmiRegistrationRequestAutomatic(),
|
||||
true,
|
||||
)
|
||||
_ = registrationSession.Close()
|
||||
cancel()
|
||||
if registrationErr != nil {
|
||||
manager.setResult(id, state, nil, registrationErr)
|
||||
return NetworkResult{}, registrationErr
|
||||
}
|
||||
}
|
||||
result, err := setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
|
||||
if err != nil && (request.Username != "" || request.Password != "") {
|
||||
// qmi-network output is outside our control and may echo values read
|
||||
@@ -272,6 +297,19 @@ func usbNetModeName(mode int) string {
|
||||
}
|
||||
|
||||
func (manager *Manager) OperatorSelection(ctx context.Context, id string) (OperatorSelection, error) {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if isNativeQMICandidate(candidate) {
|
||||
state.opMu.Lock()
|
||||
defer state.opMu.Unlock()
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
return manager.nativeQMIOperatorSelectionLocked(ctx, candidate)
|
||||
}
|
||||
response, err := manager.ExecuteAT(ctx, id, "AT+COPS?")
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
@@ -335,6 +373,18 @@ func (manager *Manager) SetOperatorSelection(
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if isNativeQMICandidate(candidate) {
|
||||
selection, err := manager.setNativeQMIOperatorSelectionLocked(
|
||||
ctx,
|
||||
candidate,
|
||||
automatic,
|
||||
plmn,
|
||||
accessTechnologyValue,
|
||||
)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return selection, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
@@ -435,6 +485,12 @@ func (manager *Manager) ReRegisterOperator(ctx context.Context, id string) (Oper
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if isNativeQMICandidate(candidate) {
|
||||
selection, err := manager.reRegisterNativeQMIOperatorLocked(ctx, candidate)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return selection, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
|
||||
+294
-8
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/i18n"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
@@ -159,12 +161,20 @@ func encodeICCID(digits 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileID := derConstruct(0xA0, derEncode(0x5A, bcd))
|
||||
return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{0xFF})), nil
|
||||
refreshFlag := byte(0x00)
|
||||
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.
|
||||
@@ -195,9 +205,74 @@ type euiccChannel struct {
|
||||
id string
|
||||
channel int
|
||||
pcscSession *pcsc.Session
|
||||
qmiSession nativeQMIEuiccSession
|
||||
qmiSlot uint8
|
||||
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
|
||||
// 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.
|
||||
@@ -289,6 +364,9 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
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
|
||||
// firmware requires the explicit one-byte expected length: Le=00 opens a
|
||||
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
|
||||
@@ -327,6 +405,38 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
||||
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) {
|
||||
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
|
||||
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
|
||||
@@ -407,6 +517,14 @@ func isTransientEuiccCME(err error) bool {
|
||||
|
||||
// close releases the logical channel (MANAGE CHANNEL close).
|
||||
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}
|
||||
_, _, _ = channel.exchange(ctx, closeAPDU)
|
||||
if channel.pcscSession != nil {
|
||||
@@ -420,6 +538,17 @@ func (channel *euiccChannel) close(ctx context.Context) {
|
||||
}
|
||||
|
||||
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 {
|
||||
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
|
||||
return payload, int(sw), err
|
||||
@@ -652,9 +781,9 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
if iccid == "" {
|
||||
return errors.New("esim: an ICCID is required")
|
||||
}
|
||||
der, err := buildEnableProfileRequest(iccid)
|
||||
if err != nil {
|
||||
return err
|
||||
_, nativeQMI, nativeErr := manager.nativeQMIControl(id)
|
||||
if nativeErr != nil {
|
||||
return nativeErr
|
||||
}
|
||||
manager.lockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
@@ -666,6 +795,31 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
manager.unlockESIM()
|
||||
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):
|
||||
// BF31 { A0 { 5A <iccid bcd> } 81 01 FF } (refresh = yes)
|
||||
@@ -675,10 +829,38 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// stays a sibling of A0, directly under BF31.
|
||||
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
|
||||
// disconnect or reverse-proxy timeout must not cancel it halfway through and
|
||||
// skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13).
|
||||
// skip post-commit recovery; EC20 may otherwise remain in SIM failure
|
||||
// (+CME 13).
|
||||
commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
|
||||
payload, err := channel.es10(commitContext, der)
|
||||
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
|
||||
// opMu only for the duration of each APDU, so by here the lock is free.
|
||||
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
|
||||
@@ -703,10 +885,48 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
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()
|
||||
return err
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
@@ -721,6 +941,8 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
return manager.verifySwitchedICCID(verifyContext, id, iccid)
|
||||
}
|
||||
|
||||
type esimCATBusyRetryKey struct{}
|
||||
|
||||
func (manager *Manager) startProfileSwitchRecovery(id string) {
|
||||
done := make(chan struct{})
|
||||
manager.esimRecoveryMu.Lock()
|
||||
@@ -853,6 +1075,18 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
||||
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) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
@@ -975,17 +1209,69 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
|
||||
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
|
||||
// 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 actually exposing the requested ICCID.
|
||||
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)
|
||||
const attempts = 6
|
||||
var lastICCID string
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
if manager.isPCSCDevice(id) {
|
||||
if control, native, nativeErr := manager.nativeQMIControl(id); native {
|
||||
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)
|
||||
if err == nil {
|
||||
lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
@@ -1019,7 +1305,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
|
||||
}
|
||||
if attempt+1 < attempts {
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
case <-time.After(interval):
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -206,6 +207,38 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.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) {
|
||||
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
|
||||
t.Fatalf("manufacturer = %q", got)
|
||||
@@ -283,6 +316,41 @@ func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.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) {
|
||||
cause := &modem.CommandError{
|
||||
Command: `AT+CSIM=10,"0070000001"`,
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -21,6 +22,7 @@ type Options struct {
|
||||
SMSTimeout time.Duration
|
||||
ScanTimeout time.Duration
|
||||
CardReaders *pcsc.Service
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -38,9 +40,15 @@ type Manager struct {
|
||||
smsTimeout time.Duration
|
||||
scanTimeout time.Duration
|
||||
cardReaders *pcsc.Service
|
||||
started bool
|
||||
devices map[string]*managedDevice
|
||||
ussdSessions map[string]ussdSession
|
||||
logger *slog.Logger
|
||||
|
||||
qmiRadioOpener qmiRadioSessionOpener
|
||||
nativeQMIRegistrationMu sync.Mutex
|
||||
nativeQMIRegistrationInFlight map[string]struct{}
|
||||
|
||||
started bool
|
||||
devices map[string]*managedDevice
|
||||
ussdSessions map[string]ussdSession
|
||||
}
|
||||
|
||||
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
|
||||
@@ -115,6 +123,11 @@ func NewManager(options Options) (*Manager, error) {
|
||||
smsTimeout: options.SMSTimeout,
|
||||
scanTimeout: options.ScanTimeout,
|
||||
cardReaders: options.CardReaders,
|
||||
logger: options.Logger,
|
||||
|
||||
qmiRadioOpener: openQMIRadioSession,
|
||||
nativeQMIRegistrationInFlight: make(map[string]struct{}),
|
||||
|
||||
devices: make(map[string]*managedDevice),
|
||||
ussdSessions: make(map[string]ussdSession),
|
||||
esimRecoveries: make(map[string]chan struct{}),
|
||||
@@ -232,7 +245,20 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
state.opMu.Unlock()
|
||||
}
|
||||
manager.resetChangedClients()
|
||||
return manager.List(), nil
|
||||
|
||||
// List retains previously discovered devices so configured hardware can be
|
||||
// rendered as offline after it is unplugged. Discover, however, is a fresh
|
||||
// physical scan and must only return devices that are present now. Returning
|
||||
// the retained entries here allowed an unplugged modem to be selected and
|
||||
// added again from the device discovery screen.
|
||||
devices := manager.List()
|
||||
present := devices[:0]
|
||||
for _, entry := range devices {
|
||||
if entry.Discovered {
|
||||
present = append(present, entry)
|
||||
}
|
||||
}
|
||||
return present, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) resetChangedClients() {
|
||||
@@ -351,10 +377,11 @@ func (manager *Manager) setResult(
|
||||
err error,
|
||||
) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.devices[id] != state {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
previousError := state.lastError
|
||||
if snapshot != nil {
|
||||
value := *snapshot
|
||||
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
||||
@@ -366,6 +393,19 @@ func (manager *Manager) setResult(
|
||||
} else {
|
||||
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 {
|
||||
|
||||
@@ -45,6 +45,29 @@ func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDiscoverReturnsOnlyCurrentlyPresentDevices(t *testing.T) {
|
||||
manager, id := newStartedTestManager(t, nil)
|
||||
if devices := manager.List(); len(devices) != 1 || devices[0].ID != id || !devices[0].Discovered {
|
||||
t.Fatalf("initial devices = %#v", devices)
|
||||
}
|
||||
|
||||
manager.discoverer = staticDiscoverer{}
|
||||
present, err := manager.Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover after unplug: %v", err)
|
||||
}
|
||||
if len(present) != 0 {
|
||||
t.Fatalf("present devices after unplug = %#v, want none", present)
|
||||
}
|
||||
|
||||
// The retained entry is still available to the configured-device dashboard,
|
||||
// but is explicitly offline and cannot be offered by fresh discovery.
|
||||
retained := manager.List()
|
||||
if len(retained) != 1 || retained[0].ID != id || retained[0].Discovered {
|
||||
t.Fatalf("retained devices after unplug = %#v", retained)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{
|
||||
@@ -141,6 +164,58 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "ATI", response: okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410")},
|
||||
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
|
||||
{command: "AT+CCID", response: modem.Response{Final: "ERROR"}, err: errors.New("CCID unsupported")},
|
||||
{command: "AT+QCCID", response: modem.Response{Final: "ERROR"}, err: errors.New("QCCID unsupported")},
|
||||
{command: "AT+CIMI", response: okResponse("234159611274418")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
|
||||
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,2")},
|
||||
{command: "AT+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) {
|
||||
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
|
||||
t.Fatalf("ASCII SPN = %q", got)
|
||||
|
||||
@@ -34,6 +34,9 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
|
||||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
||||
return "", ""
|
||||
}
|
||||
if IsPlaceholderIMSI(digits) {
|
||||
return "", ""
|
||||
}
|
||||
mcc = digits[:3]
|
||||
mnc = digits[3:]
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
@@ -45,6 +48,18 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -33,6 +33,22 @@ 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) {
|
||||
t.Parallel()
|
||||
for _, imsi := range []string{"460001234567890", "461001234567890"} {
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
// nativeQMIRegistrationSession is the QMI NAS control surface used by
|
||||
// OpenStick WWAN devices. It deliberately stays separate from
|
||||
// qmiRadioSession so AT-only devices and existing radio-control fakes do not
|
||||
// acquire a mandatory NAS implementation.
|
||||
type nativeQMIRegistrationSession interface {
|
||||
qmiRadioSession
|
||||
GetServingSystem(context.Context) (*qmi.ServingSystem, error)
|
||||
GetSystemSelectionPreference(context.Context) (*qmi.SystemSelectionPreference, error)
|
||||
SetSystemSelectionPreference(context.Context, qmi.SystemSelectionPreference) error
|
||||
InitiateNetworkRegister(context.Context, qmi.NASInitiateNetworkRegisterRequest) error
|
||||
ForceNetworkSearch(context.Context) error
|
||||
AttachDetach(context.Context, bool) error
|
||||
}
|
||||
|
||||
const (
|
||||
nativeQMIRegistrationPollInterval = 2 * time.Second
|
||||
nativeQMIRegistrationMaxAttempts = 45
|
||||
nativeQMIRegistrationRadioCycleAfterAttempts = 30
|
||||
nativeQMIRegistrationUnsupportedCycleAfterTries = 3
|
||||
nativeQMIRegistrationBackgroundTimeout = 45 * time.Second
|
||||
)
|
||||
|
||||
func isNativeQMICandidate(candidate modem.Candidate) bool {
|
||||
deviceID := strings.TrimSpace(candidate.ID)
|
||||
control := strings.TrimSpace(candidate.QMIControl)
|
||||
return nativeQMIControlMatches(deviceID, control)
|
||||
}
|
||||
|
||||
func nativeQMIControlMatches(deviceID, control string) bool {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
control = strings.TrimSpace(control)
|
||||
if deviceID == "" || control == "" {
|
||||
return false
|
||||
}
|
||||
prefix := ""
|
||||
switch {
|
||||
case strings.HasPrefix(deviceID, "wwan"):
|
||||
prefix = deviceID + "qmi"
|
||||
case strings.HasPrefix(deviceID, "mhi-wwan"):
|
||||
prefix = "wwan" + strings.TrimPrefix(deviceID, "mhi-wwan") + "qmi"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(filepath.Base(control), prefix)
|
||||
}
|
||||
|
||||
func (manager *Manager) openNativeQMIRegistration(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
) (nativeQMIRegistrationSession, error) {
|
||||
if manager == nil || manager.qmiRadioOpener == nil {
|
||||
return nil, errors.New("QMI NAS registration is unavailable")
|
||||
}
|
||||
control := strings.TrimSpace(candidate.QMIControl)
|
||||
if control == "" {
|
||||
return nil, errors.New("QMI NAS registration control device is unavailable")
|
||||
}
|
||||
session, err := manager.qmiRadioOpener(ctx, control)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nas, ok := session.(nativeQMIRegistrationSession)
|
||||
if !ok {
|
||||
_ = session.Close()
|
||||
return nil, errors.New("QMI radio session does not expose NAS registration control")
|
||||
}
|
||||
return nas, nil
|
||||
}
|
||||
|
||||
// startNativeQMIRegistrationReconcile continues registration after a radio
|
||||
// transition. Bringing DMS online only proves that the RF switch completed;
|
||||
// NAS may still report searching or PS detached seconds later, so the
|
||||
// registration sequence continues after SetFlight returns. The per-device
|
||||
// guard prevents repeated UI/poll callbacks from opening competing sessions.
|
||||
func (manager *Manager) startNativeQMIRegistrationReconcile(id string) bool {
|
||||
if manager == nil {
|
||||
return false
|
||||
}
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if !isNativeQMICandidate(candidate) {
|
||||
return false
|
||||
}
|
||||
manager.nativeQMIRegistrationMu.Lock()
|
||||
if _, running := manager.nativeQMIRegistrationInFlight[id]; running {
|
||||
manager.nativeQMIRegistrationMu.Unlock()
|
||||
return false
|
||||
}
|
||||
manager.nativeQMIRegistrationInFlight[id] = struct{}{}
|
||||
manager.nativeQMIRegistrationMu.Unlock()
|
||||
go func() {
|
||||
defer func() {
|
||||
manager.nativeQMIRegistrationMu.Lock()
|
||||
delete(manager.nativeQMIRegistrationInFlight, id)
|
||||
manager.nativeQMIRegistrationMu.Unlock()
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nativeQMIRegistrationBackgroundTimeout)
|
||||
defer cancel()
|
||||
_, _ = manager.ReRegisterOperator(ctx, id)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func qmiOperatorSelectionFromPreference(pref *qmi.SystemSelectionPreference) (OperatorSelection, error) {
|
||||
if pref == nil {
|
||||
return OperatorSelection{}, errors.New("QMI returned an empty system-selection preference")
|
||||
}
|
||||
accessTechnology := qmiAccessTechnologyFromModePreference(pref.ModePreference)
|
||||
if pref.HasManualNetworkSelection {
|
||||
mcc := fmt.Sprintf("%03d", pref.ManualNetworkSelection.MCC)
|
||||
mncWidth := 2
|
||||
if pref.ManualNetworkSelection.IncludesPCSDigit {
|
||||
mncWidth = 3
|
||||
}
|
||||
mnc := fmt.Sprintf("%0*d", mncWidth, pref.ManualNetworkSelection.MNC)
|
||||
return OperatorSelection{
|
||||
Mode: 1,
|
||||
Format: 2,
|
||||
Operator: mcc + mnc,
|
||||
AccessTechnology: accessTechnology,
|
||||
}, nil
|
||||
}
|
||||
return OperatorSelection{Mode: 0, AccessTechnology: accessTechnology}, nil
|
||||
}
|
||||
|
||||
func qmiManualRegisterRequest(
|
||||
plmn string,
|
||||
accessTechnologyValue *int,
|
||||
) (qmi.NASInitiateNetworkRegisterRequest, error) {
|
||||
mcc, mnc, includesPCSDigit, err := qmiPLMNParts(plmn)
|
||||
if err != nil {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{}, err
|
||||
}
|
||||
rat := uint8(0)
|
||||
if accessTechnologyValue != nil {
|
||||
if *accessTechnologyValue < 0 || *accessTechnologyValue > 9 {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{}, errors.New("invalid operator access technology")
|
||||
}
|
||||
rat = qmiRATFromATCode(*accessTechnologyValue)
|
||||
if rat == 0 {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{}, errors.New("unsupported operator access technology")
|
||||
}
|
||||
}
|
||||
return qmi.NASInitiateNetworkRegisterRequest{
|
||||
Mode: qmi.NASNetworkRegisterManual,
|
||||
MCC: mcc,
|
||||
MNC: mnc,
|
||||
IncludesPCSDigit: includesPCSDigit,
|
||||
RadioAccessTech: rat,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func qmiPLMNParts(plmn string) (mcc, mnc uint16, includesPCSDigit bool, err error) {
|
||||
plmn = strings.TrimSpace(plmn)
|
||||
if !decimalPLMN(plmn) {
|
||||
return 0, 0, false, errors.New("operator PLMN must contain 5 or 6 digits")
|
||||
}
|
||||
mccValue, parseErr := strconv.ParseUint(plmn[:3], 10, 16)
|
||||
if parseErr != nil {
|
||||
return 0, 0, false, fmt.Errorf("parse operator MCC: %w", parseErr)
|
||||
}
|
||||
mncValue, parseErr := strconv.ParseUint(plmn[3:], 10, 16)
|
||||
if parseErr != nil {
|
||||
return 0, 0, false, fmt.Errorf("parse operator MNC: %w", parseErr)
|
||||
}
|
||||
return uint16(mccValue), uint16(mncValue), len(plmn) == 6, nil
|
||||
}
|
||||
|
||||
func qmiManualSelectionPreference(plmn string) (qmi.SystemSelectionPreference, qmi.ManualNetworkSelection, error) {
|
||||
return qmiManualSelectionPreferenceWithRAT(plmn, nil)
|
||||
}
|
||||
|
||||
func qmiManualSelectionPreferenceWithRAT(
|
||||
plmn string,
|
||||
accessTechnologyValue *int,
|
||||
) (qmi.SystemSelectionPreference, qmi.ManualNetworkSelection, error) {
|
||||
mcc, mnc, includesPCSDigit, err := qmiPLMNParts(plmn)
|
||||
if err != nil {
|
||||
return qmi.SystemSelectionPreference{}, qmi.ManualNetworkSelection{}, err
|
||||
}
|
||||
selection := qmi.ManualNetworkSelection{
|
||||
MCC: mcc,
|
||||
MNC: mnc,
|
||||
IncludesPCSDigit: includesPCSDigit,
|
||||
}
|
||||
pref := qmi.SystemSelectionPreference{
|
||||
NetworkSelectionPreference: qmi.NASNetworkSelectionManual,
|
||||
HasNetworkSelectionPreference: true,
|
||||
ManualNetworkSelection: selection,
|
||||
HasManualNetworkSelection: true,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}
|
||||
if accessTechnologyValue != nil {
|
||||
modePreference, ok := qmiModePreferenceFromATCode(*accessTechnologyValue)
|
||||
if !ok {
|
||||
return qmi.SystemSelectionPreference{}, qmi.ManualNetworkSelection{}, errors.New("unsupported operator access technology")
|
||||
}
|
||||
pref.ModePreference = modePreference
|
||||
pref.HasModePreference = true
|
||||
}
|
||||
return pref, selection, nil
|
||||
}
|
||||
|
||||
func qmiRATFromATCode(value int) uint8 {
|
||||
switch value {
|
||||
case 0, 3: // GSM / EDGE
|
||||
return 0x04
|
||||
case 2, 4, 5, 6: // UTRAN / HSDPA / HSUPA / HSPA
|
||||
return 0x05
|
||||
case 7: // LTE
|
||||
return 0x08
|
||||
case 9: // NR5G
|
||||
return 0x0C
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func qmiModePreferenceFromATCode(value int) (uint16, bool) {
|
||||
switch value {
|
||||
case 0, 3: // GSM / EDGE
|
||||
return qmi.NASRatModePreferenceGSM, true
|
||||
case 2, 4, 5, 6: // UTRAN / HSDPA / HSUPA / HSPA
|
||||
return qmi.NASRatModePreferenceUMTS, true
|
||||
case 7: // LTE
|
||||
return qmi.NASRatModePreferenceLTE, true
|
||||
case 9: // NR5G
|
||||
return qmi.NASRatModePreferenceNR5G, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRATFromServingRadioInterface(value uint8) uint8 {
|
||||
switch value {
|
||||
case 4, 5, 8:
|
||||
return value
|
||||
case 10: // NAS serving-system NR5G value
|
||||
return 0x0C
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRATFromModePreference(value uint16) uint8 {
|
||||
switch {
|
||||
case value&qmi.NASRatModePreferenceNR5G != 0:
|
||||
return 0x0C
|
||||
case value&qmi.NASRatModePreferenceLTE != 0:
|
||||
return 0x08
|
||||
case value&qmi.NASRatModePreferenceUMTS != 0:
|
||||
return 0x05
|
||||
case value&qmi.NASRatModePreferenceGSM != 0:
|
||||
return 0x04
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func qmiAccessTechnologyFromModePreference(value uint16) string {
|
||||
switch {
|
||||
case value&qmi.NASRatModePreferenceNR5G != 0:
|
||||
return "NR5G"
|
||||
case value&qmi.NASRatModePreferenceLTE != 0:
|
||||
return "LTE"
|
||||
case value&qmi.NASRatModePreferenceUMTS != 0:
|
||||
return "UTRAN"
|
||||
case value&qmi.NASRatModePreferenceGSM != 0:
|
||||
return "GSM"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRegistrationRequestAutomatic() qmi.NASInitiateNetworkRegisterRequest {
|
||||
return qmi.NASInitiateNetworkRegisterRequest{
|
||||
Mode: qmi.NASNetworkRegisterAutomatic,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}
|
||||
}
|
||||
|
||||
func qmiSelectionAutomaticPreference() qmi.SystemSelectionPreference {
|
||||
return qmi.SystemSelectionPreference{
|
||||
NetworkSelectionPreference: qmi.NASNetworkSelectionAutomatic,
|
||||
HasNetworkSelectionPreference: true,
|
||||
ChangeDuration: qmi.NASChangeDurationPermanent,
|
||||
HasChangeDuration: true,
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsupportedQMIRegistrationCommand(err error, messageID uint16) bool {
|
||||
qmiErr := qmi.GetQMIError(err)
|
||||
if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != messageID {
|
||||
return false
|
||||
}
|
||||
switch qmiErr.ErrorCode {
|
||||
case qmi.QMIErrMalformedMsg,
|
||||
qmi.QMIErrInvalidRegisterAction,
|
||||
qmi.QMIErrNoEffect,
|
||||
qmi.QMIErrNotSupported,
|
||||
qmi.QMIErrInvalidQmiCmd,
|
||||
qmi.QMIErrOpDeviceUnsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsupportedQMIForceSearch(err error) bool {
|
||||
qmiErr := qmi.GetQMIError(err)
|
||||
if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != qmi.NASForceNetworkSearch {
|
||||
return false
|
||||
}
|
||||
return qmiErr.ErrorCode == qmi.QMIErrNotSupported ||
|
||||
qmiErr.ErrorCode == qmi.QMIErrInvalidQmiCmd ||
|
||||
qmiErr.ErrorCode == qmi.QMIErrOpDeviceUnsupported
|
||||
}
|
||||
|
||||
func isUnsupportedQMISelectionCommand(err error) bool {
|
||||
qmiErr := qmi.GetQMIError(err)
|
||||
if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != qmi.NASSetSystemSelectionPreference {
|
||||
return false
|
||||
}
|
||||
switch qmiErr.ErrorCode {
|
||||
case qmi.QMIErrMalformedMsg,
|
||||
qmi.QMIErrInvalidRegisterAction,
|
||||
qmi.QMIErrNoEffect,
|
||||
qmi.QMIErrNotSupported,
|
||||
qmi.QMIErrInvalidQmiCmd,
|
||||
qmi.QMIErrOpDeviceUnsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func qmiRegistrationStateRegistered(state qmi.RegistrationState) bool {
|
||||
return state == qmi.RegStateRegistered || state == qmi.RegStateRoaming
|
||||
}
|
||||
|
||||
func nativeQMIRegistrationRadioCycleThreshold(forceSearchUnsupported bool) int {
|
||||
if forceSearchUnsupported {
|
||||
return nativeQMIRegistrationUnsupportedCycleAfterTries
|
||||
}
|
||||
return nativeQMIRegistrationRadioCycleAfterAttempts
|
||||
}
|
||||
|
||||
// triggerNativeQMIManualRegistration applies the manual preference that was
|
||||
// written by the caller and starts a fresh NAS search. On the OpenStick 410
|
||||
// firmware, NAS_FORCE_NETWORK_SEARCH is the reliable trigger; sending
|
||||
// NAS_INITIATE_NETWORK_REGISTER with RadioAccessTech=0 is rejected as an
|
||||
// invalid profile. Older firmware may not expose force-search, so fall back
|
||||
// to an explicit RAT (or the current serving RAT) when that command is not
|
||||
// supported.
|
||||
func triggerNativeQMIManualRegistration(
|
||||
ctx context.Context,
|
||||
session nativeQMIRegistrationSession,
|
||||
request *qmi.NASInitiateNetworkRegisterRequest,
|
||||
serving *qmi.ServingSystem,
|
||||
) (forceSearchIssued bool, forceSearchUnsupported bool, err error) {
|
||||
if request == nil {
|
||||
return false, false, errors.New("QMI manual registration request is unavailable")
|
||||
}
|
||||
if err := session.ForceNetworkSearch(ctx); err == nil {
|
||||
return true, false, nil
|
||||
} else if !isUnsupportedQMIForceSearch(err) {
|
||||
return false, false, fmt.Errorf("force QMI network search: %w", err)
|
||||
}
|
||||
|
||||
forceSearchUnsupported = true
|
||||
if request.RadioAccessTech == 0 && serving != nil {
|
||||
request.RadioAccessTech = qmiRATFromServingRadioInterface(serving.RadioInterface)
|
||||
}
|
||||
if request.RadioAccessTech == 0 {
|
||||
return false, true, errors.New("QMI manual registration requires a supported radio access technology")
|
||||
}
|
||||
if err := session.InitiateNetworkRegister(ctx, *request); err != nil {
|
||||
return false, true, fmt.Errorf("initiate manual QMI network registration: %w", err)
|
||||
}
|
||||
return false, true, nil
|
||||
}
|
||||
|
||||
// ensureNativeQMIRegistration runs the NAS registration sequence used on
|
||||
// OpenStick. The modem's AT+COPS surface on this firmware only changes
|
||||
// presentation; it does not reliably drive this NAS state machine.
|
||||
func ensureNativeQMIRegistration(
|
||||
ctx context.Context,
|
||||
session nativeQMIRegistrationSession,
|
||||
request qmi.NASInitiateNetworkRegisterRequest,
|
||||
setAutomatic bool,
|
||||
) error {
|
||||
return ensureNativeQMIRegistrationForTarget(ctx, session, request, setAutomatic, nil)
|
||||
}
|
||||
|
||||
// ensureNativeQMIRegistrationForTarget is the manual-lock variant of the
|
||||
// registration sequence. A modem can remain registered on the old PLMN while
|
||||
// it processes a new manual request, so a successful registered/PS-attached
|
||||
// state is only authoritative when it is on the requested PLMN.
|
||||
func ensureNativeQMIRegistrationForTarget(
|
||||
ctx context.Context,
|
||||
session nativeQMIRegistrationSession,
|
||||
request qmi.NASInitiateNetworkRegisterRequest,
|
||||
setAutomatic bool,
|
||||
target *qmi.ManualNetworkSelection,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if session == nil {
|
||||
return errors.New("QMI NAS registration session is unavailable")
|
||||
}
|
||||
if request.Mode == 0 {
|
||||
request = qmiRegistrationRequestAutomatic()
|
||||
}
|
||||
|
||||
mode, err := session.GetOperatingMode(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read QMI operating mode: %w", err)
|
||||
}
|
||||
if mode == qmi.ModeLowPower || mode == qmi.ModeOffline || mode == qmi.ModeShutdown || mode == qmi.ModeReset {
|
||||
if err := session.SetOperatingMode(ctx, qmi.ModeOnline); err != nil {
|
||||
return fmt.Errorf("restore QMI online mode: %w", err)
|
||||
}
|
||||
if err := waitNativeQMIRegistration(ctx); err != nil {
|
||||
return fmt.Errorf("wait for QMI online mode: %w", err)
|
||||
}
|
||||
mode, err = session.GetOperatingMode(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recheck QMI operating mode: %w", err)
|
||||
}
|
||||
if mode == qmi.ModeLowPower || mode == qmi.ModeOffline || mode == qmi.ModeShutdown || mode == qmi.ModeReset {
|
||||
return fmt.Errorf("QMI operating mode remained non-online after recovery: %d", mode)
|
||||
}
|
||||
}
|
||||
|
||||
if setAutomatic {
|
||||
if err := session.SetSystemSelectionPreference(ctx, qmiSelectionAutomaticPreference()); err != nil {
|
||||
// Some OpenStick firmware accepts the preference but reports an
|
||||
// unsupported result for an optional NAS TLV. The explicit NAS register
|
||||
// below remains the authoritative trigger.
|
||||
if !isUnsupportedQMISelectionCommand(err) {
|
||||
return fmt.Errorf("restore automatic QMI NAS selection: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
registerIssued := false
|
||||
forceSearchIssued := false
|
||||
radioCycleIssued := false
|
||||
forceSearchUnsupported := false
|
||||
manualTarget := target != nil && request.Mode == qmi.NASNetworkRegisterManual
|
||||
for attempt := 1; attempt <= nativeQMIRegistrationMaxAttempts; attempt++ {
|
||||
serving, servingErr := session.GetServingSystem(ctx)
|
||||
if servingErr != nil {
|
||||
if err := waitNativeQMIRegistration(ctx); err != nil {
|
||||
return fmt.Errorf("read QMI serving system: %w", servingErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if serving == nil {
|
||||
return errors.New("QMI serving system returned no data")
|
||||
}
|
||||
if qmiRegistrationStateRegistered(serving.RegistrationState) {
|
||||
if target == nil || qmiServingSystemMatchesTarget(serving, *target) {
|
||||
if serving.PSAttached {
|
||||
return nil
|
||||
}
|
||||
if err := session.AttachDetach(ctx, true); err != nil {
|
||||
return fmt.Errorf("attach QMI packet service: %w", err)
|
||||
}
|
||||
} else if !registerIssued {
|
||||
if manualTarget {
|
||||
var triggerErr error
|
||||
forceSearchIssued, forceSearchUnsupported, triggerErr = triggerNativeQMIManualRegistration(
|
||||
ctx, session, &request, serving,
|
||||
)
|
||||
if triggerErr != nil {
|
||||
return triggerErr
|
||||
}
|
||||
} else if err := session.InitiateNetworkRegister(ctx, request); err != nil {
|
||||
return fmt.Errorf("initiate QMI network registration: %w", err)
|
||||
}
|
||||
registerIssued = true
|
||||
}
|
||||
} else if serving.RegistrationState == qmi.RegStateDenied {
|
||||
return errors.New("QMI network registration was denied")
|
||||
} else if !registerIssued {
|
||||
if manualTarget {
|
||||
var triggerErr error
|
||||
forceSearchIssued, forceSearchUnsupported, triggerErr = triggerNativeQMIManualRegistration(
|
||||
ctx, session, &request, serving,
|
||||
)
|
||||
if triggerErr != nil {
|
||||
return triggerErr
|
||||
}
|
||||
} else if err := session.InitiateNetworkRegister(ctx, request); err != nil {
|
||||
if !(setAutomatic && isUnsupportedQMIRegistrationCommand(err, qmi.NASInitiateNetworkRegister)) {
|
||||
return fmt.Errorf("initiate QMI network registration: %w", err)
|
||||
}
|
||||
}
|
||||
registerIssued = true
|
||||
}
|
||||
|
||||
searching := serving.RegistrationState == qmi.RegStateSearching
|
||||
if target != nil && qmiRegistrationStateRegistered(serving.RegistrationState) && !qmiServingSystemMatchesTarget(serving, *target) {
|
||||
searching = true
|
||||
}
|
||||
if searching && registerIssued && !forceSearchIssued && !forceSearchUnsupported && attempt >= 2 {
|
||||
forceSearchIssued = true
|
||||
if err := session.ForceNetworkSearch(ctx); err != nil {
|
||||
if isUnsupportedQMIForceSearch(err) {
|
||||
forceSearchUnsupported = true
|
||||
} else {
|
||||
return fmt.Errorf("force QMI network search: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
radioCycleAfter := nativeQMIRegistrationRadioCycleThreshold(forceSearchUnsupported)
|
||||
if searching && registerIssued && !radioCycleIssued && attempt >= radioCycleAfter {
|
||||
radioCycleIssued = true
|
||||
if err := session.SetOperatingMode(ctx, qmi.ModeLowPower); err == nil {
|
||||
_ = waitNativeQMIRegistration(ctx)
|
||||
_ = session.SetOperatingMode(ctx, qmi.ModeOnline)
|
||||
registerIssued = false
|
||||
}
|
||||
}
|
||||
if err := waitNativeQMIRegistration(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("QMI network registration/PS attach timed out after %d attempts", nativeQMIRegistrationMaxAttempts)
|
||||
}
|
||||
|
||||
func qmiServingSystemMatchesTarget(serving *qmi.ServingSystem, target qmi.ManualNetworkSelection) bool {
|
||||
return serving != nil && serving.MCC == target.MCC && serving.MNC == target.MNC
|
||||
}
|
||||
|
||||
func waitNativeQMIRegistration(ctx context.Context) error {
|
||||
timer := time.NewTimer(nativeQMIRegistrationPollInterval)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) nativeQMIOperatorSelectionLocked(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
) (OperatorSelection, error) {
|
||||
session, err := manager.openNativeQMIRegistration(ctx, candidate)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("open QMI NAS operator selection: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
pref, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err)
|
||||
}
|
||||
return qmiOperatorSelectionFromPreference(pref)
|
||||
}
|
||||
|
||||
func (manager *Manager) setNativeQMIOperatorSelectionLocked(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
automatic bool,
|
||||
plmn string,
|
||||
accessTechnologyValue *int,
|
||||
) (OperatorSelection, error) {
|
||||
session, err := manager.openNativeQMIRegistration(ctx, candidate)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("open QMI NAS operator selection: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if automatic {
|
||||
request := qmiRegistrationRequestAutomatic()
|
||||
if err := ensureNativeQMIRegistration(ctx, session, request, true); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
pref, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err)
|
||||
}
|
||||
return qmiOperatorSelectionFromPreference(pref)
|
||||
}
|
||||
request, err := qmiManualRegisterRequest(plmn, accessTechnologyValue)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
preference, target, err := qmiManualSelectionPreferenceWithRAT(plmn, accessTechnologyValue)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
// InitiateNetworkRegister is only a one-shot trigger on this firmware. The
|
||||
// manual preference must be written separately or the next reconcile will
|
||||
// read automatic selection and undo the requested lock.
|
||||
if err := session.SetSystemSelectionPreference(ctx, preference); err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("set manual QMI network selection: %w", err)
|
||||
}
|
||||
if err := ensureNativeQMIRegistrationForTarget(ctx, session, request, false, &target); err != nil {
|
||||
manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
actual, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID)
|
||||
return OperatorSelection{}, fmt.Errorf("verify manual QMI network selection: %w", err)
|
||||
}
|
||||
if actual == nil || !actual.HasManualNetworkSelection || actual.ManualNetworkSelection != target {
|
||||
manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID)
|
||||
return OperatorSelection{}, fmt.Errorf("modem did not retain manual PLMN %s", strings.TrimSpace(plmn))
|
||||
}
|
||||
return qmiOperatorSelectionFromPreference(actual)
|
||||
}
|
||||
|
||||
// restoreNativeQMISelectionAfterFailure prevents a failed manual lock from
|
||||
// leaving the modem in a searching/manual state. The caller may already have
|
||||
// exhausted its request deadline, so rollback uses a fresh bounded context and
|
||||
// schedules the normal background reconcile as a second line of defence.
|
||||
func (manager *Manager) restoreNativeQMISelectionAfterFailure(
|
||||
session nativeQMIRegistrationSession,
|
||||
deviceID string,
|
||||
) {
|
||||
if manager == nil || session == nil {
|
||||
return
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
defer cancel()
|
||||
_ = session.SetSystemSelectionPreference(rollbackCtx, qmiSelectionAutomaticPreference())
|
||||
_ = session.InitiateNetworkRegister(rollbackCtx, qmiRegistrationRequestAutomatic())
|
||||
_ = session.ForceNetworkSearch(rollbackCtx)
|
||||
if strings.TrimSpace(deviceID) != "" {
|
||||
manager.startNativeQMIRegistrationReconcile(deviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) reRegisterNativeQMIOperatorLocked(
|
||||
ctx context.Context,
|
||||
candidate modem.Candidate,
|
||||
) (OperatorSelection, error) {
|
||||
session, err := manager.openNativeQMIRegistration(ctx, candidate)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("open QMI NAS re-registration: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
pref, err := session.GetSystemSelectionPreference(ctx)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err)
|
||||
}
|
||||
request := qmiRegistrationRequestAutomatic()
|
||||
setAutomatic := true
|
||||
selection := OperatorSelection{Mode: 0}
|
||||
if pref != nil && pref.HasManualNetworkSelection {
|
||||
setAutomatic = false
|
||||
request.Mode = qmi.NASNetworkRegisterManual
|
||||
request.MCC = pref.ManualNetworkSelection.MCC
|
||||
request.MNC = pref.ManualNetworkSelection.MNC
|
||||
request.IncludesPCSDigit = pref.ManualNetworkSelection.IncludesPCSDigit
|
||||
request.ChangeDuration = qmi.NASChangeDurationPermanent
|
||||
request.HasChangeDuration = true
|
||||
if pref.HasModePreference {
|
||||
request.RadioAccessTech = qmiRATFromModePreference(pref.ModePreference)
|
||||
}
|
||||
selection, err = qmiOperatorSelectionFromPreference(pref)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
}
|
||||
var target *qmi.ManualNetworkSelection
|
||||
if pref != nil && pref.HasManualNetworkSelection {
|
||||
target = &pref.ManualNetworkSelection
|
||||
}
|
||||
if err := ensureNativeQMIRegistrationForTarget(ctx, session, request, setAutomatic, target); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
type fakeNativeQMIRegistrationSession struct {
|
||||
mode qmi.OperatingMode
|
||||
serving []*qmi.ServingSystem
|
||||
selection *qmi.SystemSelectionPreference
|
||||
setModes []qmi.OperatingMode
|
||||
setPreferences []qmi.SystemSelectionPreference
|
||||
registerRequests []qmi.NASInitiateNetworkRegisterRequest
|
||||
forceSearches int
|
||||
forceSearchErr error
|
||||
registerErr error
|
||||
attachRequests []bool
|
||||
closeCount int
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) {
|
||||
return session.mode, nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) SetOperatingMode(_ context.Context, mode qmi.OperatingMode) error {
|
||||
session.mode = mode
|
||||
session.setModes = append(session.setModes, mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) Close() error {
|
||||
session.closeCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) GetServingSystem(context.Context) (*qmi.ServingSystem, error) {
|
||||
if len(session.serving) == 0 {
|
||||
return &qmi.ServingSystem{RegistrationState: qmi.RegStateSearching}, nil
|
||||
}
|
||||
current := session.serving[0]
|
||||
if len(session.serving) > 1 {
|
||||
session.serving = session.serving[1:]
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) GetSystemSelectionPreference(context.Context) (*qmi.SystemSelectionPreference, error) {
|
||||
if session.selection == nil {
|
||||
return &qmi.SystemSelectionPreference{}, nil
|
||||
}
|
||||
return session.selection, nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) SetSystemSelectionPreference(_ context.Context, pref qmi.SystemSelectionPreference) error {
|
||||
session.selection = &pref
|
||||
session.setPreferences = append(session.setPreferences, pref)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) InitiateNetworkRegister(_ context.Context, req qmi.NASInitiateNetworkRegisterRequest) error {
|
||||
session.registerRequests = append(session.registerRequests, req)
|
||||
return session.registerErr
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) ForceNetworkSearch(context.Context) error {
|
||||
session.forceSearches++
|
||||
return session.forceSearchErr
|
||||
}
|
||||
|
||||
func (session *fakeNativeQMIRegistrationSession) AttachDetach(_ context.Context, attached bool) error {
|
||||
session.attachRequests = append(session.attachRequests, attached)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEnsureNativeQMIRegistrationDrivesNASSequence(t *testing.T) {
|
||||
session := &fakeNativeQMIRegistrationSession{
|
||||
mode: qmi.ModeLowPower,
|
||||
serving: []*qmi.ServingSystem{
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: false},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true},
|
||||
},
|
||||
}
|
||||
|
||||
if err := ensureNativeQMIRegistration(context.Background(), session, qmiRegistrationRequestAutomatic(), true); err != nil {
|
||||
t.Fatalf("ensure native QMI registration: %v", err)
|
||||
}
|
||||
if len(session.setModes) != 1 || session.setModes[0] != qmi.ModeOnline {
|
||||
t.Fatalf("operating mode writes = %#v, want [online]", session.setModes)
|
||||
}
|
||||
if len(session.setPreferences) != 1 || !session.setPreferences[0].HasNetworkSelectionPreference ||
|
||||
session.setPreferences[0].NetworkSelectionPreference != qmi.NASNetworkSelectionAutomatic {
|
||||
t.Fatalf("selection writes = %#v, want automatic", session.setPreferences)
|
||||
}
|
||||
if len(session.registerRequests) != 1 || session.registerRequests[0].Mode != qmi.NASNetworkRegisterAutomatic {
|
||||
t.Fatalf("registration requests = %#v, want one automatic request", session.registerRequests)
|
||||
}
|
||||
if session.forceSearches != 1 {
|
||||
t.Fatalf("force-search count = %d, want 1", session.forceSearches)
|
||||
}
|
||||
if len(session.attachRequests) != 1 || !session.attachRequests[0] {
|
||||
t.Fatalf("attach requests = %#v, want one attach", session.attachRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualRegisterRequestMapsPLMNAndRAT(t *testing.T) {
|
||||
rat := 7
|
||||
request, err := qmiManualRegisterRequest("46001", &rat)
|
||||
if err != nil {
|
||||
t.Fatalf("manual request: %v", err)
|
||||
}
|
||||
if request.Mode != qmi.NASNetworkRegisterManual || request.MCC != 460 || request.MNC != 1 ||
|
||||
request.IncludesPCSDigit || request.RadioAccessTech != 0x08 || !request.HasChangeDuration ||
|
||||
request.ChangeDuration != qmi.NASChangeDurationPermanent {
|
||||
t.Fatalf("manual request = %#v", request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualSelectionPreferenceMapsPLMN(t *testing.T) {
|
||||
pref, selection, err := qmiManualSelectionPreference("46001")
|
||||
if err != nil {
|
||||
t.Fatalf("manual preference: %v", err)
|
||||
}
|
||||
if pref.NetworkSelectionPreference != qmi.NASNetworkSelectionManual ||
|
||||
!pref.HasNetworkSelectionPreference || !pref.HasManualNetworkSelection ||
|
||||
!pref.HasChangeDuration || pref.ChangeDuration != qmi.NASChangeDurationPermanent {
|
||||
t.Fatalf("manual preference = %#v", pref)
|
||||
}
|
||||
if selection.MCC != 460 || selection.MNC != 1 || selection.IncludesPCSDigit {
|
||||
t.Fatalf("manual selection = %#v", selection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualSelectionPreferenceMapsRAT(t *testing.T) {
|
||||
rat := 7
|
||||
pref, _, err := qmiManualSelectionPreferenceWithRAT("46001", &rat)
|
||||
if err != nil {
|
||||
t.Fatalf("manual preference: %v", err)
|
||||
}
|
||||
if !pref.HasModePreference || pref.ModePreference != qmi.NASRatModePreferenceLTE {
|
||||
t.Fatalf("manual preference mode = %#v, want LTE mode preference", pref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQMIManualRegisterRequestRejectsUnknownRAT(t *testing.T) {
|
||||
rat := 1
|
||||
if _, err := qmiManualRegisterRequest("46001", &rat); err == nil {
|
||||
t.Fatal("manual request with unknown RAT must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNativeQMIRegistrationWaitsForManualTarget(t *testing.T) {
|
||||
session := &fakeNativeQMIRegistrationSession{
|
||||
mode: qmi.ModeOnline,
|
||||
serving: []*qmi.ServingSystem{
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 0},
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: false, MCC: 460, MNC: 1},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 1},
|
||||
},
|
||||
}
|
||||
request, err := qmiManualRegisterRequest("46001", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("manual request: %v", err)
|
||||
}
|
||||
target := qmi.ManualNetworkSelection{MCC: 460, MNC: 1}
|
||||
if err := ensureNativeQMIRegistrationForTarget(context.Background(), session, request, false, &target); err != nil {
|
||||
t.Fatalf("ensure manual registration: %v", err)
|
||||
}
|
||||
if len(session.registerRequests) != 0 {
|
||||
t.Fatalf("registration requests = %#v, want force-search-only manual trigger", session.registerRequests)
|
||||
}
|
||||
if session.forceSearches != 1 {
|
||||
t.Fatalf("force-search count = %d, want 1", session.forceSearches)
|
||||
}
|
||||
if len(session.attachRequests) != 1 || !session.attachRequests[0] {
|
||||
t.Fatalf("attach requests = %#v, want one attach", session.attachRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNativeQMIRegistrationFallsBackWhenForceSearchUnsupported(t *testing.T) {
|
||||
session := &fakeNativeQMIRegistrationSession{
|
||||
serving: []*qmi.ServingSystem{
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, RadioInterface: 8, MCC: 460, MNC: 0},
|
||||
{RegistrationState: qmi.RegStateSearching},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: false, MCC: 460, MNC: 1},
|
||||
{RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 1},
|
||||
},
|
||||
forceSearchErr: &qmi.QMIError{
|
||||
Service: qmi.ServiceNAS, MessageID: qmi.NASForceNetworkSearch,
|
||||
Result: 0x0001, ErrorCode: qmi.QMIErrNotSupported,
|
||||
},
|
||||
}
|
||||
request, err := qmiManualRegisterRequest("46001", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("manual request: %v", err)
|
||||
}
|
||||
target := qmi.ManualNetworkSelection{MCC: 460, MNC: 1}
|
||||
if err := ensureNativeQMIRegistrationForTarget(context.Background(), session, request, false, &target); err != nil {
|
||||
t.Fatalf("ensure manual registration: %v", err)
|
||||
}
|
||||
if len(session.registerRequests) != 1 || session.registerRequests[0].RadioAccessTech != 8 {
|
||||
t.Fatalf("registration requests = %#v, want one LTE fallback request", session.registerRequests)
|
||||
}
|
||||
if session.forceSearches != 1 {
|
||||
t.Fatalf("force-search count = %d, want one unsupported attempt", session.forceSearches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeQMIRegistrationCyclesEarlyWhenForceSearchUnsupported(t *testing.T) {
|
||||
if got := nativeQMIRegistrationRadioCycleThreshold(true); got != 3 {
|
||||
t.Fatalf("unsupported force-search threshold = %d, want 3", got)
|
||||
}
|
||||
if got := nativeQMIRegistrationRadioCycleThreshold(false); got != 30 {
|
||||
t.Fatalf("supported force-search threshold = %d, want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNativeQMICandidateRequiresOpenStickWWANPair(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidate modem.Candidate
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "native",
|
||||
candidate: modem.Candidate{
|
||||
ID: "wwan0",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "mhi native discovery id",
|
||||
candidate: modem.Candidate{
|
||||
ID: "mhi-wwan0",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different control device",
|
||||
candidate: modem.Candidate{
|
||||
ID: "wwan0",
|
||||
QMIControl: "/dev/cdc-wdm0",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non native id",
|
||||
candidate: modem.Candidate{
|
||||
ID: "usb0",
|
||||
QMIControl: "/dev/usb0qmi0",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isNativeQMICandidate(tt.candidate); got != tt.want {
|
||||
t.Fatalf("isNativeQMICandidate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,17 @@ 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) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
|
||||
@@ -39,6 +39,19 @@ func (manager *Manager) readSnapshot(
|
||||
if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") {
|
||||
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) {
|
||||
response, commandErr := manager.command(ctx, client, command)
|
||||
@@ -56,10 +69,23 @@ func (manager *Manager) readSnapshot(
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
||||
cancelQMI()
|
||||
if qmiErr == nil {
|
||||
snapshot.ICCID = qmiICCID
|
||||
ccidErr = nil
|
||||
} else {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID via QMI UIM: "+qmiErr.Error())
|
||||
}
|
||||
}
|
||||
if ccidErr != nil {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||
} else {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if snapshot.ICCID == "" {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
}
|
||||
}
|
||||
previousICCID = strings.TrimSpace(previousICCID)
|
||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||
@@ -159,13 +185,31 @@ func (manager *Manager) readSnapshot(
|
||||
snapshot.RegistrationStatus = 1
|
||||
snapshot.RegistrationSource = "COPS"
|
||||
}
|
||||
if response, ok := optional("AT+CGSN"); ok {
|
||||
snapshot.IMEI = parseIdentifier(
|
||||
response,
|
||||
[]string{"+CGSN:", "+GSN:"},
|
||||
14,
|
||||
17,
|
||||
)
|
||||
if snapshot.IMEI == "" {
|
||||
response, ok := optional("AT+CGSN")
|
||||
if ok {
|
||||
snapshot.IMEI = parseIdentifier(
|
||||
response,
|
||||
[]string{"+CGSN:", "+GSN:"},
|
||||
14,
|
||||
17,
|
||||
)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
@@ -21,6 +22,22 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error)
|
||||
if path == "" {
|
||||
return nil, errors.New("modem: candidate has no AT port")
|
||||
}
|
||||
if isNativeWWANATPath(path) {
|
||||
rawPort, err := openNativeWWANATTransport(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open WWAN AT port %s: %w", path, err)
|
||||
}
|
||||
if err := rawPort.ResetInputBuffer(); err != nil {
|
||||
_ = rawPort.Close()
|
||||
return nil, fmt.Errorf("reset WWAN AT input buffer %s: %w", path, err)
|
||||
}
|
||||
session, err := NewSession(rawPort, opener.SessionOptions)
|
||||
if err != nil {
|
||||
_ = rawPort.Close()
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
baudRate := opener.BaudRate
|
||||
if baudRate <= 0 {
|
||||
baudRate = 115200
|
||||
@@ -45,3 +62,8 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func isNativeWWANATPath(path string) bool {
|
||||
_, kind, _, ok := parseWWANPortName(filepath.Base(filepath.Clean(path)))
|
||||
return ok && kind == "at"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package modem
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNativeWWANATPath(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/dev/wwan0at0",
|
||||
"/dev/wwan12at3",
|
||||
} {
|
||||
if !isNativeWWANATPath(path) {
|
||||
t.Errorf("isNativeWWANATPath(%q) = false", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/dev/wwan0qmi0",
|
||||
"/dev/ttyUSB2",
|
||||
"/tmp/wwan-at",
|
||||
"/dev/wwanat0",
|
||||
} {
|
||||
if isNativeWWANATPath(path) {
|
||||
t.Errorf("isNativeWWANATPath(%q) = true", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build linux
|
||||
|
||||
package modem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// nativeWWANATTransport adapts a Linux WWAN AT character device to Session's
|
||||
// serial-like transport contract. WWAN ports are not TTYs, so termios ioctls
|
||||
// used by ordinary serial libraries fail even though raw AT read/write works.
|
||||
type nativeWWANATTransport struct {
|
||||
mu sync.RWMutex
|
||||
fd int
|
||||
readTimeout time.Duration
|
||||
closed bool
|
||||
}
|
||||
|
||||
func openNativeWWANATTransport(path string) (Transport, error) {
|
||||
fd, err := unix.Open(path, unix.O_RDWR|unix.O_NONBLOCK|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &nativeWWANATTransport{fd: fd, readTimeout: -1}, nil
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Read(buffer []byte) (int, error) {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
deadline := time.Time{}
|
||||
if transport.readTimeout >= 0 {
|
||||
deadline = time.Now().Add(transport.readTimeout)
|
||||
}
|
||||
for {
|
||||
timeout := -1
|
||||
if !deadline.IsZero() {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
timeout = int((remaining + time.Millisecond - 1) / time.Millisecond)
|
||||
}
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, timeout)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ready == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if fds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 &&
|
||||
fds[0].Revents&unix.POLLIN == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
count, err := unix.Read(transport.fd, buffer)
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Write(buffer []byte) (int, error) {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
for {
|
||||
count, err := unix.Write(transport.fd, buffer)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, unix.EAGAIN) {
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLOUT}}
|
||||
if _, pollErr := unix.Poll(fds, 1000); pollErr != nil {
|
||||
return 0, pollErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Drain() error {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
// WWAN character-device writes are handed to the modem synchronously and
|
||||
// have no termios output queue to drain.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
||||
transport.mu.RLock()
|
||||
defer transport.mu.RUnlock()
|
||||
if transport.closed {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := unix.Read(transport.fd, buffer); err != nil {
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) SetReadTimeout(timeout time.Duration) error {
|
||||
if timeout < -1 {
|
||||
return fmt.Errorf("invalid read timeout %s", timeout)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
if transport.closed {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
transport.readTimeout = timeout
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) Close() error {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
if transport.closed {
|
||||
return nil
|
||||
}
|
||||
transport.closed = true
|
||||
return unix.Close(transport.fd)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package modem
|
||||
|
||||
import "fmt"
|
||||
|
||||
func openNativeWWANATTransport(path string) (Transport, error) {
|
||||
return nil, fmt.Errorf("native WWAN AT ports are unsupported on this platform: %s", path)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package qmiport coordinates access to native Linux WWAN QMI control ports.
|
||||
package qmiport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type portHandle interface {
|
||||
Close() error
|
||||
Stat() (os.FileInfo, error)
|
||||
}
|
||||
|
||||
type portOpener func(string) (portHandle, error)
|
||||
|
||||
type entry struct {
|
||||
gate chan struct{}
|
||||
|
||||
keeperMu sync.Mutex
|
||||
keeper portHandle
|
||||
}
|
||||
|
||||
type coordinator struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*entry
|
||||
opener portOpener
|
||||
}
|
||||
|
||||
// Lease serializes one QMI transaction sequence for a control port. Release
|
||||
// does not close the keepalive descriptor: the old OpenStick 410 WWAN driver
|
||||
// removes DATA5_CNTL when the final descriptor closes, and does not reliably
|
||||
// recreate it until the modem is reset.
|
||||
type Lease struct {
|
||||
entry *entry
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
var processCoordinator = newCoordinator(openPort)
|
||||
|
||||
func newCoordinator(opener portOpener) *coordinator {
|
||||
return &coordinator{
|
||||
entries: make(map[string]*entry),
|
||||
opener: opener,
|
||||
}
|
||||
}
|
||||
|
||||
func openPort(path string) (portHandle, error) {
|
||||
return os.OpenFile(path, os.O_RDWR|syscall.O_NONBLOCK|syscall.O_NOCTTY, 0)
|
||||
}
|
||||
|
||||
// Acquire keeps path open for the process lifetime and grants exclusive QMI
|
||||
// access until the returned lease is released. A modem reset replaces the
|
||||
// device node; ensureKeeper detects that inode change and rearms the keepalive.
|
||||
func Acquire(ctx context.Context, path string) (*Lease, error) {
|
||||
return processCoordinator.acquire(ctx, path)
|
||||
}
|
||||
|
||||
func (coordinator *coordinator) acquire(ctx context.Context, path string) (*Lease, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path == "." || path == "" {
|
||||
return nil, errors.New("QMI control path is required")
|
||||
}
|
||||
coordinator.mu.Lock()
|
||||
item := coordinator.entries[path]
|
||||
if item == nil {
|
||||
item = &entry{gate: make(chan struct{}, 1)}
|
||||
item.gate <- struct{}{}
|
||||
coordinator.entries[path] = item
|
||||
}
|
||||
coordinator.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-item.gate:
|
||||
}
|
||||
if err := coordinator.ensureKeeper(path, item); err != nil {
|
||||
item.gate <- struct{}{}
|
||||
return nil, fmt.Errorf("keep QMI control port %s open: %w", path, err)
|
||||
}
|
||||
return &Lease{entry: item}, nil
|
||||
}
|
||||
|
||||
func (coordinator *coordinator) ensureKeeper(path string, item *entry) error {
|
||||
item.keeperMu.Lock()
|
||||
defer item.keeperMu.Unlock()
|
||||
|
||||
currentInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.keeper != nil {
|
||||
keeperInfo, statErr := item.keeper.Stat()
|
||||
if statErr == nil && os.SameFile(currentInfo, keeperInfo) {
|
||||
return nil
|
||||
}
|
||||
_ = item.keeper.Close()
|
||||
item.keeper = nil
|
||||
}
|
||||
keeper, err := coordinator.opener(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.keeper = keeper
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release allows the next QMI-UIM operation to use this control port.
|
||||
func (lease *Lease) Release() {
|
||||
if lease == nil || lease.entry == nil {
|
||||
return
|
||||
}
|
||||
lease.once.Do(func() {
|
||||
lease.entry.gate <- struct{}{}
|
||||
})
|
||||
}
|
||||
|
||||
func (coordinator *coordinator) close() error {
|
||||
coordinator.mu.Lock()
|
||||
entries := make([]*entry, 0, len(coordinator.entries))
|
||||
for _, item := range coordinator.entries {
|
||||
entries = append(entries, item)
|
||||
}
|
||||
coordinator.entries = make(map[string]*entry)
|
||||
coordinator.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
for _, item := range entries {
|
||||
item.keeperMu.Lock()
|
||||
if item.keeper != nil {
|
||||
errs = append(errs, item.keeper.Close())
|
||||
item.keeper = nil
|
||||
}
|
||||
item.keeperMu.Unlock()
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package qmiport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLeaseKeepsPortOpenAndSerializesUsers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "wwan0qmi0")
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var opens atomic.Int32
|
||||
coordinator := newCoordinator(func(path string) (portHandle, error) {
|
||||
opens.Add(1)
|
||||
return os.OpenFile(path, os.O_RDWR, 0)
|
||||
})
|
||||
t.Cleanup(func() { _ = coordinator.close() })
|
||||
|
||||
first, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquire: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := coordinator.acquire(ctx, path); err == nil {
|
||||
t.Fatal("second acquire succeeded before the first lease was released")
|
||||
}
|
||||
first.Release()
|
||||
|
||||
second, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("second acquire: %v", err)
|
||||
}
|
||||
second.Release()
|
||||
if got := opens.Load(); got != 1 {
|
||||
t.Fatalf("keepalive opens = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseReopensReplacedDeviceNode(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "wwan0qmi0")
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var opens atomic.Int32
|
||||
coordinator := newCoordinator(func(path string) (portHandle, error) {
|
||||
opens.Add(1)
|
||||
return os.OpenFile(path, os.O_RDWR, 0)
|
||||
})
|
||||
t.Cleanup(func() { _ = coordinator.close() })
|
||||
|
||||
first, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.Release()
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := coordinator.acquire(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second.Release()
|
||||
if got := opens.Load(); got != 2 {
|
||||
t.Fatalf("keepalive opens = %d, want 2 after node replacement", got)
|
||||
}
|
||||
}
|
||||
@@ -239,10 +239,14 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
config := payload.toStoreDevice()
|
||||
isNative410 := config.DeviceType == store.DeviceTypeWiFi410
|
||||
// Newly added hardware starts fail-closed: RF is disabled immediately and
|
||||
// VoWiFi becomes the desired service. Cellular registration is only
|
||||
// restored by the user's later airplane-mode-off action.
|
||||
// VoWiFi becomes the desired service on supported devices. Native 410
|
||||
// uses its QMI UIM/DMS/NAS adapter; only cellular SMS remains unavailable.
|
||||
config.VoWiFiEnabled = true
|
||||
if isNative410 {
|
||||
config.SMSEnabled = false
|
||||
}
|
||||
config.NetworkEnabled = false
|
||||
if !s.developerActive(r.Context()) {
|
||||
config.NetworkEnabled = false
|
||||
@@ -284,7 +288,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.vowifi != nil {
|
||||
if s.vowifi != nil && config.VoWiFiEnabled {
|
||||
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)
|
||||
}
|
||||
@@ -344,7 +348,14 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": []any{}}})
|
||||
return true
|
||||
}
|
||||
devices := s.devices.List()
|
||||
// This endpoint backs the add-device dialog. Always perform a new physical
|
||||
// scan instead of serving Manager.List(), which intentionally retains
|
||||
// unplugged configured devices so the main device list can show them offline.
|
||||
devices, err := s.devices.Discover(r.Context())
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
configured, err := s.store.ListDevices(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
@@ -352,6 +363,9 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
result := make([]map[string]any, 0, len(devices))
|
||||
for _, entry := range devices {
|
||||
if !entry.Discovered {
|
||||
continue
|
||||
}
|
||||
candidate := entry.Candidate
|
||||
atPorts := make([]string, 0, len(candidate.Ports))
|
||||
for _, port := range candidate.Ports {
|
||||
@@ -502,6 +516,10 @@ func (s *Server) handleDevicePath(
|
||||
}
|
||||
|
||||
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 {
|
||||
operation := strings.Join(tail, "/")
|
||||
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
|
||||
@@ -653,6 +671,14 @@ func (s *Server) handleDevicePath(
|
||||
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 {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@@ -1395,9 +1421,9 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
||||
default:
|
||||
// Device errors may echo an AT command. Authentication commands can
|
||||
// contain APN credentials, so keep raw errors out of logs and responses.
|
||||
s.logger.Warn("device operation failed")
|
||||
// Preserve the hardware failure reason in the operator-visible log while
|
||||
// keeping AT payloads and long APDU material out of it.
|
||||
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
|
||||
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
||||
}
|
||||
}
|
||||
@@ -1472,7 +1498,13 @@ func physicalMatchesConfig(entry device.Device, config store.Device) bool {
|
||||
return config.ModemIMEI == entry.Snapshot.IMEI
|
||||
}
|
||||
if config.USBPath != "" && candidate.USBPath != "" {
|
||||
return config.USBPath == candidate.USBPath
|
||||
if config.USBPath == candidate.USBPath {
|
||||
return true
|
||||
}
|
||||
// Sysfs paths may be stored through /sys/class symlinks while a
|
||||
// subsequent discovery returns the resolved device path. Keep checking
|
||||
// the selected AT/QMI nodes instead of rejecting a modem whose physical
|
||||
// path spelling changed but whose control plane is unchanged.
|
||||
}
|
||||
// Control and serial device nodes are allocation-order dependent. They are
|
||||
// only legacy fallbacks when no physical USB path or readable IMEI exists.
|
||||
@@ -1654,56 +1686,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
||||
enabled, _ := extra["enabled"].(bool)
|
||||
active, _ := extra["active"].(bool)
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"carrier_profile": extra["carrier_profile"],
|
||||
"carrier_profile_from": extra["carrier_profile_from"],
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
}
|
||||
}
|
||||
|
||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"carrier_profile": runtime.CarrierProfile,
|
||||
"carrier_profile_from": runtime.CarrierProfileFrom,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": map[string]any{
|
||||
"established": runtime.TunnelReady,
|
||||
"name": runtime.TunnelName,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
type discoverySnapshotController struct {
|
||||
fakeDeviceController
|
||||
entries []device.Device
|
||||
discoverCalls int
|
||||
}
|
||||
|
||||
func (controller *discoverySnapshotController) Discover(context.Context) ([]device.Device, error) {
|
||||
controller.discoverCalls++
|
||||
return append([]device.Device(nil), controller.entries...), nil
|
||||
}
|
||||
|
||||
func TestDiscoveredDevicesPerformsFreshScanAndOmitsAbsentEntries(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
controller := &discoverySnapshotController{
|
||||
fakeDeviceController: fakeDeviceController{entry: device.Device{
|
||||
ID: "stale-device", Discovered: false,
|
||||
Candidate: modem.Candidate{ID: "stale-device", USBPath: "1-1"},
|
||||
}},
|
||||
entries: []device.Device{
|
||||
{
|
||||
ID: "current-device", Discovered: true,
|
||||
Candidate: modem.Candidate{ID: "current-device", USBPath: "2-1"},
|
||||
},
|
||||
{
|
||||
ID: "absent-device", Discovered: false,
|
||||
Candidate: modem.Candidate{ID: "absent-device", USBPath: "3-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
server := &Server{
|
||||
store: database, logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096, devices: controller,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/devices/discovered", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
if !server.handleDiscoveredDevices(recorder, request) {
|
||||
t.Fatal("handleDiscoveredDevices returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if controller.discoverCalls != 1 {
|
||||
t.Fatalf("Discover calls = %d, want 1", controller.discoverCalls)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, "current-device") {
|
||||
t.Fatalf("response omits current device: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "stale-device") || strings.Contains(body, "absent-device") {
|
||||
t.Fatalf("response contains an absent device: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,29 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
|
||||
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) {
|
||||
profiles := parseModemAPNProfiles([]string{
|
||||
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
|
||||
@@ -117,6 +140,27 @@ func TestPhysicalMatchesConfigRejectsDuplicateAndroidSerialAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalMatchesConfigFallsBackWhenWWANSysfsPathWasResolved(t *testing.T) {
|
||||
config := store.Device{
|
||||
ID: "wwan0",
|
||||
USBPath: "/sys/class/wwan/wwan0",
|
||||
ATPort: "/dev/wwan0at0",
|
||||
ControlDevice: "/dev/wwan0qmi0",
|
||||
}
|
||||
entry := device.Device{
|
||||
ID: "mhi-wwan0",
|
||||
Candidate: modem.Candidate{
|
||||
USBPath: "/sys/devices/platform/soc/4080000.remoteproc/wwan/wwan0",
|
||||
ATPort: modem.Port{Path: "/dev/wwan0at0"},
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
HardwareKind: "wwan",
|
||||
},
|
||||
}
|
||||
if !physicalMatchesConfig(entry, config) {
|
||||
t.Fatal("resolved WWAN sysfs path should fall back to matching control nodes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDiscoveredDevicePrefersPhysicalIdentityOverSerialAlias(t *testing.T) {
|
||||
alias := "/dev/serial/by-id/usb-Android_Android-if02-port0"
|
||||
devices := []device.Device{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func esimUnavailable(w http.ResponseWriter) {
|
||||
@@ -400,15 +401,41 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
|
||||
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
|
||||
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
|
||||
// back after the mandatory modem reset.
|
||||
// CFUN=4. Devices that consume the requested eUICC REFRESH stay online;
|
||||
// older AT modems enter the reset recovery path and reapply CFUN=4 when the
|
||||
// port returns.
|
||||
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
|
||||
// which normally takes longer than the server's ordinary response deadline.
|
||||
// A confirmed profile switch always includes a live ICCID read and may also
|
||||
// include the EC20 reset fallback, so it can exceed the ordinary deadline.
|
||||
controller := http.NewResponseController(w)
|
||||
_ = controller.SetWriteDeadline(time.Time{})
|
||||
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
|
||||
@@ -454,6 +481,10 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
||||
s.writeStoreError(w, err)
|
||||
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
|
||||
if s.vowifi != nil {
|
||||
state, stateErr := s.vowifi.State(configuredID)
|
||||
@@ -484,6 +515,40 @@ 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) {
|
||||
if s.devices == nil {
|
||||
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 {
|
||||
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
|
||||
case strings.Contains(err.Error(), "between 6 and 1024"):
|
||||
case errors.Is(err, auth.ErrEmptyPassword):
|
||||
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
|
||||
case strings.Contains(err.Error(), "must differ"):
|
||||
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/i18n"
|
||||
localproxy "vocat/internal/proxy"
|
||||
"vocat/internal/store"
|
||||
@@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
payload.ID = id
|
||||
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:
|
||||
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if listErr != nil {
|
||||
@@ -117,7 +161,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}})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
w.Header().Set("Allow", "PUT, PATCH, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var proxyCountries = []proxyCountry{
|
||||
var namedProxyCountries = []proxyCountry{
|
||||
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
||||
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
||||
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
||||
@@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{
|
||||
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
||||
{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
|
||||
}
|
||||
|
||||
@@ -167,6 +167,11 @@ type VoWiFiController interface {
|
||||
RequestReconnect(string) (vowifi.State, error)
|
||||
}
|
||||
|
||||
type VoWiFiMaintenanceController interface {
|
||||
BeginMaintenance(string) error
|
||||
EndMaintenance(string)
|
||||
}
|
||||
|
||||
type VoWiFiCallController interface {
|
||||
Calls(string) ([]vowifi.Call, error)
|
||||
DialCall(context.Context, string, string) (vowifi.Call, error)
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errUnsafeDestination = errors.New("notification destination is not public")
|
||||
errUnsafeDestination = errors.New("notification destination is not allowed")
|
||||
errProviderRejected = errors.New("notification provider rejected the test")
|
||||
telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
|
||||
)
|
||||
@@ -459,7 +459,7 @@ func (s *Server) handleNotificationTest(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"unsafe_destination",
|
||||
"notification destination must resolve only to public network addresses",
|
||||
"notification destination resolved to an unusable or protected system address",
|
||||
)
|
||||
case errors.Is(err, errProviderRejected):
|
||||
writeError(
|
||||
@@ -905,11 +905,12 @@ func restrictedHTTPClient(
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(proxy) != "" {
|
||||
parsed, err := validateOutboundURL(ctx, proxy, false)
|
||||
parsed, err := validateNotificationProxyURL(ctx, proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate notification proxy: %w", err)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
transport.DialContext = notificationProxyDialer(timeout)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
@@ -952,6 +953,17 @@ func validateOutboundURL(
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolveNotificationProxyAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||
@@ -985,17 +997,42 @@ func restrictedDialer(timeout time.Duration) func(
|
||||
}
|
||||
}
|
||||
|
||||
func notificationProxyDialer(timeout time.Duration) func(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (net.Conn, error) {
|
||||
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
return dialNotification(ctx, network, address, timeout, true)
|
||||
}
|
||||
}
|
||||
|
||||
func dialRestricted(
|
||||
ctx context.Context,
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
) (net.Conn, error) {
|
||||
return dialNotification(ctx, network, address, timeout, false)
|
||||
}
|
||||
|
||||
func dialNotification(
|
||||
ctx context.Context,
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
allowLocal bool,
|
||||
) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse outbound address: %w", err)
|
||||
}
|
||||
addresses, err := resolvePublicAddresses(ctx, host)
|
||||
var addresses []netip.Addr
|
||||
if allowLocal {
|
||||
addresses, err = resolveNotificationProxyAddresses(ctx, host)
|
||||
} else {
|
||||
addresses, err = resolvePublicAddresses(ctx, host)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1071,54 +1108,68 @@ func dialRestricted(
|
||||
if len(failures) == 0 {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
|
||||
return nil, fmt.Errorf("dial notification destination: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
type notificationAllowedNetworksKey struct{}
|
||||
|
||||
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
return context.Background()
|
||||
}
|
||||
access := s.currentAccessConfig()
|
||||
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
|
||||
// Notification delivery is outbound administrator-configured traffic. It
|
||||
// must not inherit the inbound Web access policy: DNS Fake-IP ranges, LAN
|
||||
// gateways, and local proxies are valid notification paths.
|
||||
return ctx
|
||||
}
|
||||
|
||||
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
|
||||
func notificationAddressAllowed(_ context.Context, address netip.Addr) bool {
|
||||
address = address.Unmap()
|
||||
// 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") {
|
||||
if !notificationTransportAddress(address) {
|
||||
return false
|
||||
}
|
||||
if publicNotificationAddress(address) {
|
||||
return true
|
||||
}
|
||||
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(address) {
|
||||
for _, fakeIP := range notificationFakeIPNetworks {
|
||||
if fakeIP.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
if !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, blocked := range blockedNotificationDestinationNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func notificationProxyAddressAllowed(address netip.Addr) bool {
|
||||
return notificationTransportAddress(address.Unmap())
|
||||
}
|
||||
|
||||
func notificationTransportAddress(address netip.Addr) bool {
|
||||
return address.IsValid() && !address.IsUnspecified() && !address.IsMulticast() &&
|
||||
!address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() &&
|
||||
address != netip.MustParseAddr("255.255.255.255") &&
|
||||
address != netip.MustParseAddr("100.100.100.200")
|
||||
}
|
||||
|
||||
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveNotificationAddresses(ctx, host, false)
|
||||
}
|
||||
|
||||
func resolveNotificationProxyAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveNotificationAddresses(ctx, host, true)
|
||||
}
|
||||
|
||||
func resolveNotificationAddresses(ctx context.Context, host string, allowLocal bool) ([]netip.Addr, error) {
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
if normalized == "" || normalized == "localhost" ||
|
||||
strings.HasSuffix(normalized, ".localhost") ||
|
||||
normalized == "metadata" ||
|
||||
strings.HasSuffix(normalized, ".internal") ||
|
||||
strings.HasSuffix(normalized, ".local") {
|
||||
if normalized == "" {
|
||||
return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
|
||||
}
|
||||
if literal, err := netip.ParseAddr(normalized); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if !notificationAddressAllowed(ctx, literal) {
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, literal)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(literal)) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
@@ -1133,7 +1184,8 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !notificationAddressAllowed(ctx, address) {
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, address)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(address)) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
||||
}
|
||||
result = append(result, address)
|
||||
@@ -1141,7 +1193,11 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var blockedNotificationNetworks = []netip.Prefix{
|
||||
var notificationFakeIPNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
}
|
||||
|
||||
var blockedNotificationDestinationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
@@ -1152,7 +1208,6 @@ var blockedNotificationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
@@ -1167,19 +1222,6 @@ var blockedNotificationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
|
||||
func publicNotificationAddress(address netip.Addr) bool {
|
||||
if !address.IsValid() || !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
for _, blocked := range blockedNotificationNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func configString(config map[string]any, key string) string {
|
||||
value, _ := config[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
|
||||
@@ -369,6 +369,10 @@ func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) {
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
if response["error"].(map[string]any)["code"] != "unsafe_destination" {
|
||||
t.Fatalf("Telegram metadata response = %#v", response)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
@@ -762,26 +766,28 @@ func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||
func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testing.T) {
|
||||
blocked := []string{
|
||||
"0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
|
||||
"169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1",
|
||||
"::1", "fc00::1", "fe80::1", "2001:db8::1",
|
||||
"169.254.169.254", "172.16.0.1", "192.168.1.1", "224.0.0.1",
|
||||
"255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::1",
|
||||
}
|
||||
for _, text := range blocked {
|
||||
address := netip.MustParseAddr(text)
|
||||
if publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly accepted as public", text)
|
||||
if notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly accepted for notification transport", text)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
|
||||
for _, text := range []string{
|
||||
"1.1.1.1", "198.18.0.1", "2606:4700:4700::1111",
|
||||
} {
|
||||
address := netip.MustParseAddr(text)
|
||||
if !publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly blocked", text)
|
||||
if !notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly blocked for notification transport", text)
|
||||
}
|
||||
}
|
||||
if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
|
||||
t.Fatal("localhost was not blocked")
|
||||
t.Fatal("local notification destination was not blocked")
|
||||
}
|
||||
if _, err := resolvePublicAddresses(
|
||||
context.Background(),
|
||||
@@ -789,27 +795,53 @@ func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||
); err == nil {
|
||||
t.Fatal("metadata IP was not blocked")
|
||||
}
|
||||
allowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
)
|
||||
if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
||||
t.Fatalf("explicit Fake-IP notification allowlist = %v, %v", addresses, err)
|
||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
||||
notificationContext := server.notificationDestinationContext(context.Background())
|
||||
if addresses, err := resolvePublicAddresses(notificationContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
||||
t.Fatalf("Fake-IP notification destination = %v, %v", addresses, err)
|
||||
}
|
||||
if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
|
||||
t.Fatal("unlisted metadata IP was allowed")
|
||||
}
|
||||
wideAllowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
|
||||
)
|
||||
for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} {
|
||||
if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil {
|
||||
t.Fatalf("non-overridable destination %s was allowed", address)
|
||||
}
|
||||
|
||||
func TestNotificationProxyAcceptsLocalAddressWithoutWebAccessAllowlist(t *testing.T) {
|
||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
||||
ctx := server.notificationDestinationContext(context.Background())
|
||||
for _, host := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "198.18.0.1", "::1"} {
|
||||
if addresses, err := resolveNotificationProxyAddresses(ctx, host); err != nil || len(addresses) != 1 {
|
||||
t.Errorf("local notification proxy %s = %v, %v", host, addresses, err)
|
||||
}
|
||||
}
|
||||
if _, err := resolveNotificationProxyAddresses(ctx, "169.254.169.254"); err == nil {
|
||||
t.Fatal("cloud metadata address was accepted as a notification proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientConnectsThroughLocalProxy(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
hits.Add(1)
|
||||
if request.URL.Host != "1.1.1.1" {
|
||||
t.Errorf("proxy request host = %q", request.URL.Host)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
client, err := restrictedHTTPClient(context.Background(), 2*time.Second, proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent || hits.Load() != 1 {
|
||||
t.Fatalf("local proxy status = %d, hits = %d", response.StatusCode, hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||
|
||||
@@ -236,6 +236,10 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeStoreError(w, err)
|
||||
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)
|
||||
if !s.requirePhysicalDevice(w, present) {
|
||||
return
|
||||
@@ -667,7 +671,8 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
}
|
||||
|
||||
func supportsModemSMSStorage(config store.Device) bool {
|
||||
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
|
||||
deviceType := store.NormalizeDeviceType(config.DeviceType)
|
||||
return deviceType != store.DeviceTypeUSBSIMReader && deviceType != store.DeviceTypeWiFi410
|
||||
}
|
||||
|
||||
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
|
||||
|
||||
@@ -57,6 +57,15 @@ 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) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
|
||||
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -58,10 +57,8 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
|
||||
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
}}}
|
||||
func TestTelegramPollingAcceptsFakeIPWithoutWebAccessAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{mode: "internal"}}}
|
||||
ctx := bot.notificationDestinationContext(context.Background())
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil {
|
||||
t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err)
|
||||
|
||||
+46
-17
@@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value 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 errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
value, err := normalizeDeviceProxyBinding(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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, `
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
||||
profile_name = excluded.profile_name,
|
||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||
updated_at = excluded.updated_at
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
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) {
|
||||
return deviceProxyBinding(s.db.QueryRowContext(
|
||||
ctx,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -22,3 +28,26 @@ func TestAssetNamesFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAssetWithProgressVerifiesPublishedSize(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("vocat"), 4096)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
var destination bytes.Buffer
|
||||
asset := &Asset{Name: "vocat-test", BrowserDownloadURL: server.URL, Size: int64(len(payload))}
|
||||
if err := downloadAssetWithProgress(context.Background(), logger, asset, "", &destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(destination.Bytes(), payload) {
|
||||
t.Fatal("downloaded asset content differs")
|
||||
}
|
||||
|
||||
asset.Size++
|
||||
if err := downloadAssetWithProgress(context.Background(), logger, asset, "", io.Discard); err == nil {
|
||||
t.Fatal("download with a mismatched published size succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Release mirrors the subset of the GitHub releases API response that the
|
||||
@@ -40,6 +43,23 @@ const (
|
||||
DefaultRepository = "MengMengCode/VoCat"
|
||||
)
|
||||
|
||||
var githubHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// LatestRelease fetches the newest published release for repo (form
|
||||
// "owner/name"). A non-empty token is sent as a Bearer header, which is
|
||||
// required for private repositories and lifts the unauthenticated rate limit.
|
||||
@@ -61,7 +81,7 @@ func LatestRelease(ctx context.Context, repo, token string) (*Release, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: fetch latest release: %w", err)
|
||||
}
|
||||
@@ -120,7 +140,7 @@ func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: download asset: %w", err)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"vocat/internal/buildinfo"
|
||||
@@ -149,7 +151,7 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
}()
|
||||
|
||||
logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL)
|
||||
if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
|
||||
if err := downloadAssetWithProgress(ctx, logger, asset, opts.Token, tmp); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
@@ -206,6 +208,68 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
return nil
|
||||
}
|
||||
|
||||
type downloadProgressWriter struct {
|
||||
destination io.Writer
|
||||
downloaded atomic.Int64
|
||||
}
|
||||
|
||||
func (writer *downloadProgressWriter) Write(data []byte) (int, error) {
|
||||
written, err := writer.destination.Write(data)
|
||||
writer.downloaded.Add(int64(written))
|
||||
return written, err
|
||||
}
|
||||
|
||||
func downloadAssetWithProgress(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
asset *Asset,
|
||||
token string,
|
||||
destination io.Writer,
|
||||
) error {
|
||||
progress := &downloadProgressWriter{destination: destination}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
downloaded := progress.downloaded.Load()
|
||||
percent := float64(0)
|
||||
if asset.Size > 0 {
|
||||
percent = float64(downloaded) * 100 / float64(asset.Size)
|
||||
}
|
||||
logger.Info(
|
||||
"download progress",
|
||||
"asset", asset.Name,
|
||||
"downloaded", downloaded,
|
||||
"total", asset.Size,
|
||||
"percent", fmt.Sprintf("%.1f", percent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := downloadAsset(ctx, asset.BrowserDownloadURL, token, progress)
|
||||
close(done)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asset.Size > 0 && progress.downloaded.Load() != asset.Size {
|
||||
return fmt.Errorf(
|
||||
"update: asset size mismatch for %s: downloaded %d bytes, expected %d",
|
||||
asset.Name,
|
||||
progress.downloaded.Load(),
|
||||
asset.Size,
|
||||
)
|
||||
}
|
||||
logger.Info("download completed", "asset", asset.Name, "bytes", progress.downloaded.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExecutable catches incompatible architectures and missing dynamic
|
||||
// loaders before the working installation is touched. A valid checksum alone
|
||||
// cannot detect those packaging errors.
|
||||
|
||||
@@ -1,52 +1,338 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const att310280EPDG = "epdg.epc.att.net"
|
||||
const (
|
||||
CarrierProfileStandard = "standard-3gpp"
|
||||
IKEProposalModern = "modern"
|
||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||
IMSProfileStandard = "standard"
|
||||
IMSProfileO2Germany = "o2-germany"
|
||||
IMSProfileATT = "att"
|
||||
)
|
||||
|
||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
||||
// VoWiFi access gateway.
|
||||
// CarrierProfile contains only interoperability choices that cannot be
|
||||
// reliably discovered from the SIM or negotiated with the network. All
|
||||
// protocol layers consume this common result so their carrier handling cannot
|
||||
// drift into separate MCC/MNC switch statements.
|
||||
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
|
||||
}
|
||||
|
||||
type carrierProfileDocument struct {
|
||||
Version int `json:"version"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
}
|
||||
|
||||
type carrierProfileRule struct {
|
||||
ID string `json:"id"`
|
||||
Match carrierProfileMatch `json:"match"`
|
||||
Route carrierProfileRoute `json:"route"`
|
||||
EPDG carrierProfileEPDG `json:"epdg"`
|
||||
IKE carrierProfileIKE `json:"ike"`
|
||||
IMS carrierProfileIMS `json:"ims"`
|
||||
}
|
||||
|
||||
type carrierProfileMatch struct {
|
||||
HomePLMNs []string `json:"home_plmns"`
|
||||
IMSIPrefixes []string `json:"imsi_prefixes"`
|
||||
ICCIDPrefixes []string `json:"iccid_prefixes"`
|
||||
SPNs []string `json:"spns"`
|
||||
GID1Prefixes []string `json:"gid1_prefixes"`
|
||||
GID2Prefixes []string `json:"gid2_prefixes"`
|
||||
}
|
||||
|
||||
type carrierProfileRoute struct {
|
||||
MCC string `json:"mcc"`
|
||||
MNC string `json:"mnc"`
|
||||
}
|
||||
|
||||
type carrierProfileEPDG struct {
|
||||
Hostname string `json:"hostname"`
|
||||
DNSHosts []string `json:"dns_hosts"`
|
||||
DNSClientSubnet string `json:"dns_client_subnet"`
|
||||
}
|
||||
|
||||
type carrierProfileIKE struct {
|
||||
Proposal string `json:"proposal"`
|
||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only"`
|
||||
}
|
||||
|
||||
type carrierProfileIMS struct {
|
||||
Transport string `json:"transport"`
|
||||
IdentityProfile string `json:"identity_profile"`
|
||||
RegisterProfile string `json:"register_profile"`
|
||||
IPSecEncryption string `json:"ipsec_encryption"`
|
||||
SMSCenter string `json:"sms_center"`
|
||||
}
|
||||
|
||||
//go:embed carrier_profiles.json
|
||||
var carrierProfilesJSON []byte
|
||||
|
||||
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||
|
||||
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||
}
|
||||
if document.Version != 1 {
|
||||
panic(fmt.Sprintf("vowifi: 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 == "" {
|
||||
panic("vowifi: carrier profile ID is empty")
|
||||
}
|
||||
if _, duplicate := seen[rule.ID]; duplicate {
|
||||
panic("vowifi: duplicate carrier profile " + rule.ID)
|
||||
}
|
||||
seen[rule.ID] = struct{}{}
|
||||
if !validCarrierProfileRule(*rule) {
|
||||
panic("vowifi: invalid carrier profile " + rule.ID)
|
||||
}
|
||||
}
|
||||
return document.Profiles
|
||||
}
|
||||
|
||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
match := rule.Match
|
||||
if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, plmn := range match.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == "" {
|
||||
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
|
||||
}
|
||||
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",
|
||||
}
|
||||
bestScore := -1
|
||||
for _, rule := range builtinCarrierProfiles {
|
||||
score, source, matched := matchCarrierProfile(rule.Match, identity)
|
||||
if !matched || score <= bestScore {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
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)
|
||||
return base
|
||||
}
|
||||
|
||||
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) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
switch {
|
||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
||||
return "234", "15", true
|
||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
||||
// the initial VoWiFi provisioning path.
|
||||
return "234", "30", true
|
||||
default:
|
||||
return "", "", false
|
||||
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
|
||||
if len(identity.IMSI) >= 5 {
|
||||
identity.HomeMCC = identity.IMSI[:3]
|
||||
for _, length := range []int{3, 2} {
|
||||
if len(identity.IMSI) < 3+length {
|
||||
continue
|
||||
}
|
||||
identity.HomeMNC = identity.IMSI[3 : 3+length]
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.RouteMCC != "" {
|
||||
return profile.RouteMCC, profile.RouteMNC, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
||||
// drift between protocol layers.
|
||||
func IsATT310280(identity SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
||||
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
|
||||
}
|
||||
|
||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
if strings.TrimSpace(identity.EPDG) != "" {
|
||||
return identity
|
||||
}
|
||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
switch {
|
||||
case profile.EPDG != "":
|
||||
identity.EPDG = profile.EPDG
|
||||
case profile.RouteMCC != "":
|
||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||
}
|
||||
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 builtinCarrierProfiles {
|
||||
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 {
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
for len(mnc) < 3 {
|
||||
|
||||
@@ -54,3 +54,47 @@ 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"id": "xesim-lebara-vodafone-uk",
|
||||
"match": {
|
||||
"home_plmns": ["20404"],
|
||||
"imsi_prefixes": ["204047"],
|
||||
"iccid_prefixes": ["894416"]
|
||||
},
|
||||
"route": { "mcc": "234", "mnc": "15" },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||
},
|
||||
{
|
||||
"id": "ctexcel-ee-uk",
|
||||
"match": {
|
||||
"home_plmns": ["23433"],
|
||||
"imsi_prefixes": ["23433"],
|
||||
"iccid_prefixes": ["894430"]
|
||||
},
|
||||
"route": { "mcc": "234", "mnc": "30" }
|
||||
},
|
||||
{
|
||||
"id": "att-us",
|
||||
"match": {
|
||||
"home_plmns": ["310280"],
|
||||
"imsi_prefixes": ["310280"]
|
||||
},
|
||||
"epdg": { "hostname": "epdg.epc.att.net" },
|
||||
"ims": {
|
||||
"identity_profile": "att",
|
||||
"register_profile": "att",
|
||||
"ipsec_encryption": "aes-cbc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "o2-germany",
|
||||
"match": { "home_plmns": ["26203"] },
|
||||
"ike": { "advertise_eap_only": false },
|
||||
"ims": {
|
||||
"register_profile": "o2-germany",
|
||||
"ipsec_encryption": "null"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vodafone-uk",
|
||||
"match": { "home_plmns": ["23415"] },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" },
|
||||
"ims": { "sms_center": "+447785016005" }
|
||||
},
|
||||
{
|
||||
"id": "vodafone-netherlands",
|
||||
"match": { "home_plmns": ["20404"] },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||
},
|
||||
{
|
||||
"id": "o2-uk",
|
||||
"match": { "home_plmns": ["23410"] },
|
||||
"ims": {
|
||||
"transport": "udp",
|
||||
"sms_center": "+447802000332"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vodafone-germany",
|
||||
"match": { "home_plmns": ["26202"] },
|
||||
"epdg": {
|
||||
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
|
||||
"dns_client_subnet": "109.192.0.0/24"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -173,6 +173,13 @@ func (adapter *EC20Adapter) ReadIdentity(
|
||||
HomeMCC: homeMCC,
|
||||
HomeMNC: homeMNC,
|
||||
}
|
||||
if reader, ok := adapter.executor.(SIMMetadataReader); ok {
|
||||
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||
}
|
||||
}
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[iccid] = ec20SIMBinding{
|
||||
|
||||
@@ -10,19 +10,12 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const googleDNSOverHTTPS = "https://dns.google/resolve"
|
||||
|
||||
// A small number of operators publish the standard ePDG CNAME globally but
|
||||
// return its A records only when the recursive DNS query appears to originate
|
||||
// in the home country. Keep this list deliberately narrow: ordinary ePDGs must
|
||||
// continue to use the host resolver, and a fallback is attempted only after
|
||||
// that resolver has failed.
|
||||
var geoRestrictedEPDGSubnets = map[string]string{
|
||||
"epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany
|
||||
}
|
||||
|
||||
type dnsOverHTTPSResponse struct {
|
||||
Status int `json:"Status"`
|
||||
Answer []struct {
|
||||
@@ -41,7 +34,7 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
subnet := geoRestrictedEPDGSubnets[normalized]
|
||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||
if subnet == "" {
|
||||
if systemErr != nil {
|
||||
return nil, systemErr
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,17 +21,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Random io.Reader
|
||||
Resolver *net.Resolver
|
||||
Dialer *net.Dialer
|
||||
RootCAs *x509.CertPool
|
||||
ResponderPublicKey crypto.PublicKey
|
||||
ServerName string
|
||||
Timeout time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
Installer ChildSAInstaller
|
||||
IdentityType uint8
|
||||
APN string
|
||||
Random io.Reader
|
||||
Resolver *net.Resolver
|
||||
Dialer *net.Dialer
|
||||
RootCAs *x509.CertPool
|
||||
ResponderPublicKey crypto.PublicKey
|
||||
ServerName string
|
||||
Timeout time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
Installer ChildSAInstaller
|
||||
IdentityType uint8
|
||||
APN string
|
||||
AutoProposalFallback bool
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -42,6 +45,9 @@ func NewProvider(config Config) (*Provider, error) {
|
||||
if config.Random == nil {
|
||||
config.Random = rand.Reader
|
||||
}
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
if config.Resolver == nil {
|
||||
config.Resolver = net.DefaultResolver
|
||||
}
|
||||
@@ -77,6 +83,30 @@ func NewProvider(config Config) (*Provider, error) {
|
||||
}
|
||||
|
||||
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
|
||||
if provider == nil {
|
||||
return nil, errors.New("ike: nil provider")
|
||||
}
|
||||
session, err := provider.start(ctx, request, false)
|
||||
if err == nil || !provider.config.AutoProposalFallback {
|
||||
return session, err
|
||||
}
|
||||
profile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||
if profile.ID != vowifi.CarrierProfileStandard || !retryableLegacyProposal(err) {
|
||||
return nil, err
|
||||
}
|
||||
provider.config.Logger.Warn("IKE ePDG rejected modern proposal; trying bounded legacy fallback",
|
||||
"carrier_profile", profile.ID, "from_proposal", vowifi.IKEProposalModern,
|
||||
"to_proposal", vowifi.IKEProposalLegacy, "error", err)
|
||||
session, fallbackErr := provider.start(ctx, request, true)
|
||||
if fallbackErr != nil {
|
||||
return nil, errors.Join(err, fmt.Errorf("ike: legacy proposal fallback failed: %w", fallbackErr))
|
||||
}
|
||||
provider.config.Logger.Info("IKE automatic legacy proposal fallback succeeded",
|
||||
"carrier_profile", profile.ID, "proposal", vowifi.IKEProposalLegacy)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) start(ctx context.Context, request vowifi.TunnelRequest, forceLegacy bool) (vowifi.TunnelSession, error) {
|
||||
if provider == nil {
|
||||
return nil, errors.New("ike: nil provider")
|
||||
}
|
||||
@@ -110,8 +140,12 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
}()
|
||||
|
||||
group := uint16(dhMODP2048)
|
||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
carrierProfile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||
legacyFirst := carrierProfile.IKEProposal == vowifi.IKEProposalLegacy
|
||||
if forceLegacy {
|
||||
legacyFirst = true
|
||||
}
|
||||
advertiseEAPOnly := carrierProfile.AdvertiseEAPOnly
|
||||
if legacyFirst {
|
||||
group = dhMODP1024
|
||||
}
|
||||
@@ -582,30 +616,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func legacyIKEProfile(mcc, mnc string) bool {
|
||||
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
|
||||
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
|
||||
// that Vodafone NL core; treating them as a generic modern network causes
|
||||
// IKE_SA_INIT to fail before EAP-AKA even begins.
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "23415" || plmn == "2044"
|
||||
}
|
||||
|
||||
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
|
||||
// Android exposes the ePDG authentication method as carrier policy rather
|
||||
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
|
||||
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
|
||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
||||
}
|
||||
|
||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "2623"
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
@@ -719,6 +729,27 @@ func decryptAndValidate(
|
||||
return header, payloads, nil
|
||||
}
|
||||
|
||||
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||
|
||||
type invalidKEPayloadError struct {
|
||||
group uint16
|
||||
}
|
||||
|
||||
func (err *invalidKEPayloadError) Error() string {
|
||||
if err.group != 0 {
|
||||
return fmt.Sprintf("ike: responder requires DH group %d", err.group)
|
||||
}
|
||||
return "ike: responder reported INVALID_KE_PAYLOAD"
|
||||
}
|
||||
|
||||
func retryableLegacyProposal(err error) bool {
|
||||
if errors.Is(err, errNoProposalChosen) {
|
||||
return true
|
||||
}
|
||||
var invalidKE *invalidKEPayloadError
|
||||
return errors.As(err, &invalidKE) && (invalidKE.group == 0 || invalidKE.group == dhMODP1024)
|
||||
}
|
||||
|
||||
func rejectFatalNotifications(payloads []payload) error {
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
@@ -727,12 +758,12 @@ func rejectFatalNotifications(payloads []payload) error {
|
||||
}
|
||||
switch kind {
|
||||
case notifyNoProposal:
|
||||
return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||
return errNoProposalChosen
|
||||
case notifyInvalidKE:
|
||||
if len(data) == 2 {
|
||||
return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data))
|
||||
return &invalidKEPayloadError{group: binary.BigEndian.Uint16(data)}
|
||||
}
|
||||
return errors.New("ike: responder reported INVALID_KE_PAYLOAD")
|
||||
return &invalidKEPayloadError{}
|
||||
}
|
||||
if kind < 16384 {
|
||||
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
|
||||
|
||||
@@ -25,15 +25,37 @@ func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
||||
{mcc: "204", mnc: "04"},
|
||||
{mcc: "204", mnc: "004"},
|
||||
} {
|
||||
if !legacyIKEProfile(item.mcc, item.mnc) {
|
||||
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
|
||||
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
||||
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
||||
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
||||
}
|
||||
}
|
||||
if legacyIKEProfile("234", "87") {
|
||||
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errNoProposalChosen,
|
||||
&invalidKEPayloadError{},
|
||||
&invalidKEPayloadError{group: dhMODP1024},
|
||||
} {
|
||||
if !retryableLegacyProposal(err) {
|
||||
t.Errorf("negotiation failure %v was not retryable", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{
|
||||
&invalidKEPayloadError{group: dhMODP2048},
|
||||
errors.New("ike: authentication failed"),
|
||||
vowifi.ErrEAPAuthenticationRejected,
|
||||
} {
|
||||
if retryableLegacyProposal(err) {
|
||||
t.Errorf("unsafe failure %v enabled legacy retry", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
for index := range destination {
|
||||
destination[index] = reader.value
|
||||
|
||||
@@ -171,11 +171,12 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
||||
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
||||
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
||||
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
+201
-58
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -35,38 +36,50 @@ var (
|
||||
// LocalAddress is empty, Provider uses the corresponding value proven by the
|
||||
// TunnelSession. The default transport is TCP and the default port is 5060.
|
||||
type Config struct {
|
||||
PCSCF string
|
||||
LocalAddress string
|
||||
Transport string
|
||||
TransportByPLMN map[string]string
|
||||
Port int
|
||||
RegistrationExpiry time.Duration
|
||||
TransactionTimeout time.Duration
|
||||
PrivateIdentity string
|
||||
PublicIdentity string
|
||||
UserAgent string
|
||||
SecurityMode SecurityMode
|
||||
IPSecInstaller IPSecSAInstaller
|
||||
ProtectedClientPort int
|
||||
ProtectedServerPort int
|
||||
PCSCF string
|
||||
LocalAddress string
|
||||
Transport string
|
||||
TransportByPLMN map[string]string
|
||||
// AutoTransportFallback tries the alternate TCP/UDP transport only when
|
||||
// the initial P-CSCF attempt produced no SIP response at all. A challenge
|
||||
// or rejection is authoritative and is never retried as another transport.
|
||||
AutoTransportFallback bool
|
||||
Port int
|
||||
RegistrationExpiry time.Duration
|
||||
TransactionTimeout time.Duration
|
||||
PrivateIdentity string
|
||||
PublicIdentity string
|
||||
UserAgent string
|
||||
SecurityMode SecurityMode
|
||||
IPSecInstaller IPSecSAInstaller
|
||||
ProtectedClientPort int
|
||||
ProtectedServerPort int
|
||||
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
|
||||
// and AT+CSCA empty. It must be an international or national digit string.
|
||||
SMSCenter string
|
||||
// SMSCenterByPLMN provides narrow carrier fallbacks without applying one
|
||||
// operator's service-centre address to every SIM.
|
||||
SMSCenterByPLMN map[string]string
|
||||
// OnSMS is invoked after a valid inbound RP-DATA/SMS-DELIVER has been
|
||||
// decoded. Returning an error causes an RP-ERROR delivery report.
|
||||
OnSMS func(context.Context, ReceivedSMS) error
|
||||
// OnSMSStatus is invoked for an SMS-STATUS-REPORT received after a
|
||||
// submission that requested a delivery report.
|
||||
OnSMSStatus func(context.Context, ReceivedSMSStatus) error
|
||||
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
|
||||
// not include message text or raw protocol payloads.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
|
||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||
// runtime dependency outside the Go standard library.
|
||||
type Provider struct {
|
||||
aka vowifi.AKAProvider
|
||||
config Config
|
||||
installer IPSecSAInstaller
|
||||
aka vowifi.AKAProvider
|
||||
config Config
|
||||
installer IPSecSAInstaller
|
||||
transportMu sync.RWMutex
|
||||
transportCache map[string]string
|
||||
}
|
||||
|
||||
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||
@@ -81,10 +94,16 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||
if installer == nil {
|
||||
installer = defaultIPSecInstaller()
|
||||
}
|
||||
return &Provider{aka: aka, config: normalized, installer: installer}, nil
|
||||
return &Provider{
|
||||
aka: aka, config: normalized, installer: installer,
|
||||
transportCache: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeConfig(config Config) (Config, error) {
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
if config.Port == 0 {
|
||||
config.Port = defaultSIPPort
|
||||
}
|
||||
@@ -120,6 +139,19 @@ func normalizeConfig(config Config) (Config, error) {
|
||||
transportByPLMN[plmn] = transport
|
||||
}
|
||||
config.TransportByPLMN = transportByPLMN
|
||||
smsCenterByPLMN := make(map[string]string, len(config.SMSCenterByPLMN))
|
||||
for plmn, smsCenter := range config.SMSCenterByPLMN {
|
||||
plmn = strings.TrimSpace(plmn)
|
||||
smsCenter = strings.TrimSpace(smsCenter)
|
||||
if !digitsBetween(plmn, 5, 6) {
|
||||
return Config{}, fmt.Errorf("ims: invalid SMS service-centre PLMN %q", plmn)
|
||||
}
|
||||
if !validSMSCenter(smsCenter) {
|
||||
return Config{}, fmt.Errorf("ims: invalid SMS service-centre address for PLMN %s", plmn)
|
||||
}
|
||||
smsCenterByPLMN[plmn] = smsCenter
|
||||
}
|
||||
config.SMSCenterByPLMN = smsCenterByPLMN
|
||||
if strings.TrimSpace(config.UserAgent) == "" {
|
||||
config.UserAgent = "vocat/1"
|
||||
}
|
||||
@@ -156,15 +188,17 @@ func normalizeConfig(config Config) (Config, error) {
|
||||
config.PublicIdentity = strings.TrimSpace(config.PublicIdentity)
|
||||
config.UserAgent = strings.TrimSpace(config.UserAgent)
|
||||
config.SMSCenter = strings.TrimSpace(config.SMSCenter)
|
||||
if config.SMSCenter != "" {
|
||||
digits := strings.TrimPrefix(config.SMSCenter, "+")
|
||||
if !digitsBetween(digits, 3, 20) {
|
||||
return Config{}, errors.New("ims: configured SMS service-centre address is invalid")
|
||||
}
|
||||
if config.SMSCenter != "" && !validSMSCenter(config.SMSCenter) {
|
||||
return Config{}, errors.New("ims: configured SMS service-centre address is invalid")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func validSMSCenter(value string) bool {
|
||||
digits := strings.TrimPrefix(strings.TrimSpace(value), "+")
|
||||
return digitsBetween(digits, 3, 20)
|
||||
}
|
||||
|
||||
func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) (vowifi.IMSSession, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -199,10 +233,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
||||
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||
}
|
||||
transport := transportForIdentity(provider.config, request.Identity)
|
||||
if transport == "" {
|
||||
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
|
||||
if cached := provider.cachedTransport(request.Identity); cached != "" {
|
||||
transport = cached
|
||||
carrierSelected = true
|
||||
}
|
||||
if transport == "" && !carrierSelected {
|
||||
transport = transportHint
|
||||
}
|
||||
if transport == "" {
|
||||
transport = provider.config.Transport
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "tcp"
|
||||
}
|
||||
@@ -225,31 +266,96 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
||||
}
|
||||
|
||||
connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err)
|
||||
transports := []string{transport}
|
||||
if provider.config.AutoTransportFallback {
|
||||
alternate := "udp"
|
||||
if transport == "udp" {
|
||||
alternate = "tcp"
|
||||
}
|
||||
transports = append(transports, alternate)
|
||||
}
|
||||
session, err := newSession(provider, request, identities, endpoint, transport, connection)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := session.establish(ctx); err != nil {
|
||||
var lastErr error
|
||||
for attempt, candidate := range transports {
|
||||
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||
if dialErr != nil {
|
||||
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
||||
if attempt+1 < len(transports) && ctx.Err() == nil {
|
||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||
if sessionErr != nil {
|
||||
_ = connection.Close()
|
||||
return nil, sessionErr
|
||||
}
|
||||
establishErr := session.establish(ctx)
|
||||
if establishErr == nil {
|
||||
provider.rememberTransport(request.Identity, candidate)
|
||||
if attempt > 0 {
|
||||
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
||||
"transport", candidate)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
sipResponseObserved := session.evidence.LastSIPCode != 0
|
||||
session.abort()
|
||||
return nil, err
|
||||
lastErr = establishErr
|
||||
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||
}
|
||||
return session, nil
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||
if transport, selected := carrierTransportForIdentity(config, identity); selected {
|
||||
return transport
|
||||
}
|
||||
return config.Transport
|
||||
}
|
||||
|
||||
func carrierTransportForIdentity(config Config, identity vowifi.SIMIdentity) (string, bool) {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||
return transport, true
|
||||
}
|
||||
if transport := vowifi.ResolveCarrierProfile(identity).IMSTransport; transport != "" {
|
||||
return transport, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func transportCacheKey(identity vowifi.SIMIdentity) string {
|
||||
if iccid := strings.TrimSpace(identity.ICCID); iccid != "" {
|
||||
return "iccid:" + iccid
|
||||
}
|
||||
return "plmn:" + strings.TrimSpace(identity.HomeMCC) + "/" + strings.TrimSpace(identity.HomeMNC)
|
||||
}
|
||||
|
||||
func (provider *Provider) cachedTransport(identity vowifi.SIMIdentity) string {
|
||||
provider.transportMu.RLock()
|
||||
transport := provider.transportCache[transportCacheKey(identity)]
|
||||
provider.transportMu.RUnlock()
|
||||
return transport
|
||||
}
|
||||
|
||||
func (provider *Provider) rememberTransport(identity vowifi.SIMIdentity, transport string) {
|
||||
provider.transportMu.Lock()
|
||||
provider.transportCache[transportCacheKey(identity)] = transport
|
||||
provider.transportMu.Unlock()
|
||||
}
|
||||
|
||||
func (provider *Provider) logTransportFallback(identity vowifi.SIMIdentity, from, to string, err error) {
|
||||
provider.config.Logger.Warn("IMS P-CSCF did not respond; trying alternate SIP transport",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(identity).ID,
|
||||
"from_transport", from, "to_transport", to, "error", err)
|
||||
}
|
||||
|
||||
type identitySet struct {
|
||||
domain string
|
||||
private string
|
||||
@@ -273,7 +379,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||
privateDomain := domain
|
||||
publicDomain := domain
|
||||
if vowifi.IsATT310280(identity) {
|
||||
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
|
||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||
// the generic 3GPP PLMN IMS domain.
|
||||
domain = "one.att.net"
|
||||
@@ -595,18 +701,11 @@ func newSession(
|
||||
}
|
||||
|
||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||
if usesO2GermanyIMSProfile(identity) {
|
||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
||||
}
|
||||
|
||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
return mcc+mnc == "2623"
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
|
||||
}
|
||||
|
||||
func (session *Session) abort() {
|
||||
@@ -926,19 +1025,33 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
method: "REGISTER",
|
||||
})
|
||||
}
|
||||
deadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
transactionDeadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(transactionDeadline) {
|
||||
transactionDeadline = contextDeadline
|
||||
}
|
||||
readUDP := session.protectedUDP
|
||||
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
|
||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||
return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||
}
|
||||
if protectedUDP {
|
||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||
return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||
setReadDeadline := func(deadline time.Time) error {
|
||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||
}
|
||||
if protectedUDP {
|
||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
retransmitInterval := time.Duration(0)
|
||||
readDeadline := transactionDeadline
|
||||
if session.transport == "udp" {
|
||||
retransmitInterval = sipMessageRetransmitT1
|
||||
if candidate := time.Now().Add(retransmitInterval); candidate.Before(readDeadline) {
|
||||
readDeadline = candidate
|
||||
}
|
||||
}
|
||||
if err := setReadDeadline(readDeadline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stopCancellation := context.AfterFunc(ctx, func() {
|
||||
_ = session.conn.SetDeadline(time.Now())
|
||||
@@ -951,6 +1064,7 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
|
||||
}
|
||||
|
||||
retransmissions := 0
|
||||
for {
|
||||
var response *sipResponse
|
||||
var err error
|
||||
@@ -979,6 +1093,31 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
if contextErr := ctx.Err(); contextErr != nil {
|
||||
return nil, contextErr
|
||||
}
|
||||
var networkErr net.Error
|
||||
if retransmitInterval > 0 && errors.As(err, &networkErr) && networkErr.Timeout() &&
|
||||
time.Now().Before(transactionDeadline) {
|
||||
retransmitInterval *= 2
|
||||
if retransmitInterval > sipMessageRetransmitMax {
|
||||
retransmitInterval = sipMessageRetransmitMax
|
||||
}
|
||||
nextDeadline := time.Now().Add(retransmitInterval)
|
||||
if nextDeadline.After(transactionDeadline) {
|
||||
nextDeadline = transactionDeadline
|
||||
}
|
||||
// The previous read deadline has already expired and net.Conn applies
|
||||
// it to writes too. Extend it before retransmitting.
|
||||
if deadlineErr := setReadDeadline(nextDeadline); deadlineErr != nil {
|
||||
return nil, deadlineErr
|
||||
}
|
||||
if _, writeErr := session.conn.Write(request); writeErr != nil {
|
||||
return nil, fmt.Errorf("ims: retransmit SIP REGISTER: %w", writeErr)
|
||||
}
|
||||
retransmissions++
|
||||
session.provider.config.Logger.Debug("IMS SIP REGISTER retransmitted",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"transport", session.transport, "attempt", retransmissions)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
|
||||
@@ -989,6 +1128,10 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
continue
|
||||
}
|
||||
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
||||
retransmitInterval = 0
|
||||
if err := setReadDeadline(transactionDeadline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return response, nil
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,6 +20,40 @@ type evidenceTunnel struct {
|
||||
evidence vowifi.TunnelEvidence
|
||||
}
|
||||
|
||||
type immediateTimeoutError struct{}
|
||||
|
||||
func (immediateTimeoutError) Error() string { return "test timeout" }
|
||||
func (immediateTimeoutError) Timeout() bool { return true }
|
||||
func (immediateTimeoutError) Temporary() bool { return true }
|
||||
|
||||
type registerRetransmitConn struct {
|
||||
writes int
|
||||
response []byte
|
||||
}
|
||||
|
||||
func (connection *registerRetransmitConn) Read(destination []byte) (int, error) {
|
||||
if connection.writes < 2 {
|
||||
return 0, immediateTimeoutError{}
|
||||
}
|
||||
return copy(destination, connection.response), nil
|
||||
}
|
||||
|
||||
func (connection *registerRetransmitConn) Write(source []byte) (int, error) {
|
||||
connection.writes++
|
||||
return len(source), nil
|
||||
}
|
||||
|
||||
func (*registerRetransmitConn) Close() error { return nil }
|
||||
func (*registerRetransmitConn) LocalAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
|
||||
}
|
||||
func (*registerRetransmitConn) RemoteAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
|
||||
}
|
||||
func (*registerRetransmitConn) SetDeadline(time.Time) error { return nil }
|
||||
func (*registerRetransmitConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (*registerRetransmitConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
|
||||
return tunnel.evidence
|
||||
}
|
||||
@@ -73,6 +109,82 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
||||
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
||||
}
|
||||
if got := transportForIdentity(Config{
|
||||
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
||||
}, identity); got != "tcp" {
|
||||
t.Fatalf("explicit configuration did not override profile: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCachesSuccessfulTransportPerSIM(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := &Provider{transportCache: make(map[string]string)}
|
||||
first := vowifi.SIMIdentity{ICCID: "8901000000000000001", HomeMCC: "001", HomeMNC: "01"}
|
||||
second := vowifi.SIMIdentity{ICCID: "8901000000000000002", HomeMCC: "001", HomeMNC: "01"}
|
||||
provider.rememberTransport(first, "udp")
|
||||
if got := provider.cachedTransport(first); got != "udp" {
|
||||
t.Fatalf("cached first transport = %q", got)
|
||||
}
|
||||
if got := provider.cachedTransport(second); got != "" {
|
||||
t.Fatalf("second SIM inherited cached transport %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPRegisterRetransmitsBeforeTransactionTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
connection := ®isterRetransmitConn{response: []byte(strings.Join([]string{
|
||||
"SIP/2.0 200 OK",
|
||||
"Call-ID: register-retransmit-test",
|
||||
"CSeq: 7 REGISTER",
|
||||
"Content-Length: 0",
|
||||
"",
|
||||
"",
|
||||
}, "\r\n"))}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
TransactionTimeout: 3 * time.Second,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"}},
|
||||
transport: "udp",
|
||||
conn: connection,
|
||||
callID: "register-retransmit-test",
|
||||
}
|
||||
response, err := session.exchange(context.Background(), []byte("REGISTER test"), 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != 200 || connection.writes != 2 {
|
||||
t.Fatalf("response=%#v writes=%d, want SIP 200 after one retransmission", response, connection.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
|
||||
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
|
||||
" 23410 ": " +447802000332 ",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeConfig() error = %v", err)
|
||||
}
|
||||
if got := config.SMSCenterByPLMN["23410"]; got != "+447802000332" {
|
||||
t.Fatalf("normalized O2 SMSC = %q", got)
|
||||
}
|
||||
for _, invalid := range []Config{
|
||||
{SMSCenterByPLMN: map[string]string{"234": "+447802000332"}},
|
||||
{SMSCenterByPLMN: map[string]string{"23410": "not-a-number"}},
|
||||
} {
|
||||
if _, err := normalizeConfig(invalid); err == nil {
|
||||
t.Fatalf("normalizeConfig(%#v) succeeded", invalid.SMSCenterByPLMN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRegisterAKAParseEvidenceAndClose(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -2,10 +2,17 @@ package ims
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,7 +22,11 @@ import (
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const smsContentType = "application/vnd.3gpp.sms"
|
||||
const (
|
||||
smsContentType = "application/vnd.3gpp.sms"
|
||||
sipMessageRetransmitT1 = 500 * time.Millisecond
|
||||
sipMessageRetransmitMax = 4 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSMSCUnavailable = errors.New("ims: SMS service-centre address is unavailable")
|
||||
@@ -152,6 +163,11 @@ func (session *Session) readInboundTCP(connection net.Conn) {
|
||||
for {
|
||||
packet, err := readSIPPacket(reader)
|
||||
if err != nil {
|
||||
if !session.isClosed() && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS protected SIP packet read failed", nil,
|
||||
"stage", "sip_parse", "transport", "tcp",
|
||||
"remote", connection.RemoteAddr().String(), "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
session.dispatchPacket(packet, func(response []byte) error {
|
||||
@@ -174,6 +190,9 @@ func (session *Session) readProtectedUDP() {
|
||||
}
|
||||
packet, err := parseSIPPacket(buffer[:count])
|
||||
if err != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS protected SIP packet parse failed", nil,
|
||||
"stage", "sip_parse", "transport", "udp", "remote", remote.String(),
|
||||
"packet_bytes", count, "error", err)
|
||||
continue
|
||||
}
|
||||
session.dispatchPacket(packet, func(response []byte) error {
|
||||
@@ -198,6 +217,8 @@ func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) er
|
||||
response := packet.Response
|
||||
cseq, method, err := cseqNumber(response.value("CSeq"))
|
||||
if err != nil {
|
||||
session.logOutboundSMS(slog.LevelWarn, "IMS SIP response could not be matched",
|
||||
"stage", "sip_response", "sip_status", response.StatusCode, "error", err)
|
||||
return
|
||||
}
|
||||
key := sipTransactionKey{
|
||||
@@ -213,6 +234,10 @@ func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) er
|
||||
case channel <- response:
|
||||
default:
|
||||
}
|
||||
} else if method == "MESSAGE" {
|
||||
session.logOutboundSMS(slog.LevelWarn, "IMS SIP MESSAGE response was unmatched",
|
||||
"stage", "sip_response", "call_id", key.callID,
|
||||
"cseq", key.cseq, "sip_status", response.StatusCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -240,22 +265,64 @@ func (session *Session) exchangeRuntime(
|
||||
session.transactionsMu.Unlock()
|
||||
}()
|
||||
|
||||
session.writeMu.Lock()
|
||||
_, err := session.conn.Write(request)
|
||||
session.writeMu.Unlock()
|
||||
if err != nil {
|
||||
writeRequest := func() error {
|
||||
session.writeMu.Lock()
|
||||
defer session.writeMu.Unlock()
|
||||
_, err := session.conn.Write(request)
|
||||
return err
|
||||
}
|
||||
if err := writeRequest(); err != nil {
|
||||
return nil, fmt.Errorf("ims: send SIP %s: %w", key.method, err)
|
||||
}
|
||||
timer := time.NewTimer(session.provider.config.TransactionTimeout)
|
||||
defer timer.Stop()
|
||||
var retransmitTimer *time.Timer
|
||||
var retransmit <-chan time.Time
|
||||
retransmitInterval := sipMessageRetransmitT1
|
||||
retransmitCount := 0
|
||||
if session.transport == "udp" && key.method == "MESSAGE" {
|
||||
retransmitTimer = time.NewTimer(retransmitInterval)
|
||||
retransmit = retransmitTimer.C
|
||||
defer retransmitTimer.Stop()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
if retransmitTimer != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"ims: SIP %s transaction timed out after %d retransmissions",
|
||||
key.method,
|
||||
retransmitCount,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("ims: SIP %s transaction timed out", key.method)
|
||||
case <-retransmit:
|
||||
if err := writeRequest(); err != nil {
|
||||
return nil, fmt.Errorf("ims: retransmit SIP %s: %w", key.method, err)
|
||||
}
|
||||
retransmitCount++
|
||||
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE retransmitted",
|
||||
"stage", "sip_retransmit", "call_id", key.callID,
|
||||
"cseq", key.cseq, "attempt", retransmitCount)
|
||||
retransmitInterval *= 2
|
||||
if retransmitInterval > sipMessageRetransmitMax {
|
||||
retransmitInterval = sipMessageRetransmitMax
|
||||
}
|
||||
retransmitTimer.Reset(retransmitInterval)
|
||||
case response := <-responses:
|
||||
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
||||
if retransmitTimer != nil {
|
||||
if !retransmitTimer.Stop() {
|
||||
select {
|
||||
case <-retransmitTimer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
retransmitInterval = sipMessageRetransmitMax
|
||||
retransmitTimer.Reset(retransmitInterval)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return response, nil
|
||||
@@ -271,23 +338,44 @@ func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byt
|
||||
switch request.Method {
|
||||
case "OPTIONS":
|
||||
case "MESSAGE":
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.SplitN(request.value("Content-Type"), ";", 2)[0]))
|
||||
if contentType != smsContentType {
|
||||
if !supportsSMSContentType(request.value("Content-Type")) {
|
||||
status = 415
|
||||
}
|
||||
default:
|
||||
status = 405
|
||||
}
|
||||
response, err := buildSIPResponse(request, status, session.fromTag)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
if err != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SIP request response failed", request,
|
||||
"stage", "sip_response_build", "error", err)
|
||||
} else if err = respond(response); err != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SIP request response failed", request,
|
||||
"stage", "sip_response_send", "sip_status", status, "error", err)
|
||||
}
|
||||
if status != 200 || request.Method != "MESSAGE" {
|
||||
if request.Method == "MESSAGE" {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS MESSAGE rejected", request,
|
||||
"stage", "content_type", "sip_status", status)
|
||||
}
|
||||
return
|
||||
}
|
||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS MESSAGE received", request,
|
||||
"stage", "sip_accepted")
|
||||
go session.processSMSMessage(request)
|
||||
}
|
||||
|
||||
func supportsSMSContentType(value string) bool {
|
||||
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(mediaType, smsContentType) {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(mediaType, "multipart/mixed") &&
|
||||
strings.TrimSpace(parameters["boundary"]) != ""
|
||||
}
|
||||
|
||||
func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, error) {
|
||||
reason := map[int]string{200: "OK", 405: "Method Not Allowed", 415: "Unsupported Media Type", 488: "Not Acceptable Here"}[status]
|
||||
if reason == "" {
|
||||
@@ -325,24 +413,46 @@ func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, erro
|
||||
}
|
||||
|
||||
func (session *Session) processSMSMessage(request *sipRequest) {
|
||||
rpdu, err := parseRPDU(request.Body)
|
||||
payload, payloadSource, err := extractSMSPayload(request)
|
||||
if err != nil {
|
||||
session.sendDeliveryReport(request, buildRPError(0, 95))
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
|
||||
"stage", "mime", "error", err)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(0, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
rpdu, err := parseRPDU(payload)
|
||||
if err != nil {
|
||||
reference := byte(0)
|
||||
if len(payload) > 1 {
|
||||
reference = payload[1]
|
||||
}
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
|
||||
"stage", "rpdu", "payload_source", payloadSource,
|
||||
"rp_reference", int(reference), "payload_bytes", len(payload), "error", err)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(reference, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
if rpdu.messageType != 1 { // RP-DATA, network to MS.
|
||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS control message received", request,
|
||||
"stage", "rpdu", "payload_source", payloadSource,
|
||||
"rp_message_type", int(rpdu.messageType), "rp_reference", int(rpdu.reference))
|
||||
return
|
||||
}
|
||||
message, err := device.DecodeSMSDeliverTPDU(rpdu.tpdu)
|
||||
if err != nil {
|
||||
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
|
||||
"stage", "tpdu", "payload_source", payloadSource,
|
||||
"rp_reference", int(rpdu.reference), "tpdu_bytes", len(rpdu.tpdu), "error", err)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
receivedAt := time.Now().UTC()
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
if message.Direction == device.SMSDirectionStatusReport {
|
||||
if message.MessageReference == nil || message.StatusCode == nil {
|
||||
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
|
||||
"stage", "tpdu", "rp_reference", int(rpdu.reference))
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
status := ReceivedSMSStatus{
|
||||
@@ -357,7 +467,7 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
||||
Timestamp: receivedAt,
|
||||
RPReference: int(rpdu.reference),
|
||||
CallID: callID,
|
||||
RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)),
|
||||
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
||||
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
||||
}
|
||||
if session.provider.config.OnSMSStatus != nil {
|
||||
@@ -366,14 +476,21 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
||||
cancel()
|
||||
}
|
||||
if err != nil {
|
||||
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22))
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status persistence failed", request,
|
||||
"stage", "status_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
|
||||
return
|
||||
}
|
||||
session.sendDeliveryReport(request, []byte{0x02, rpdu.reference})
|
||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS status report processed", request,
|
||||
"stage", "status_callback", "rp_reference", int(rpdu.reference),
|
||||
"status_code", *message.StatusCode)
|
||||
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
||||
return
|
||||
}
|
||||
if message.Direction != device.SMSDirectionReceived {
|
||||
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
|
||||
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
var serviceCenterTimestamp *time.Time
|
||||
@@ -396,7 +513,7 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
||||
Concat: message.Concat,
|
||||
RPReference: int(rpdu.reference),
|
||||
CallID: callID,
|
||||
RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)),
|
||||
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
||||
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
||||
}
|
||||
if session.provider.config.OnSMS != nil {
|
||||
@@ -405,26 +522,130 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
||||
cancel()
|
||||
}
|
||||
if err != nil {
|
||||
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22))
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
|
||||
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
|
||||
return
|
||||
}
|
||||
session.sendDeliveryReport(request, []byte{0x02, rpdu.reference})
|
||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
|
||||
"stage", "sms_callback", "payload_source", payloadSource,
|
||||
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
|
||||
"concatenated", message.Concat != nil)
|
||||
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
||||
}
|
||||
|
||||
func (session *Session) sendDeliveryReport(request *sipRequest, report []byte) {
|
||||
func extractSMSPayload(request *sipRequest) ([]byte, string, error) {
|
||||
if request == nil {
|
||||
return nil, "", errors.New("ims: SMS MESSAGE is nil")
|
||||
}
|
||||
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(request.value("Content-Type")))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("ims: parse SMS Content-Type: %w", err)
|
||||
}
|
||||
if strings.EqualFold(mediaType, smsContentType) {
|
||||
payload, decodeErr := decodeSMSTransfer(request.Body, request.value("Content-Transfer-Encoding"))
|
||||
return payload, smsContentType, decodeErr
|
||||
}
|
||||
if !strings.EqualFold(mediaType, "multipart/mixed") {
|
||||
return nil, "", fmt.Errorf("ims: unsupported SMS Content-Type %q", mediaType)
|
||||
}
|
||||
boundary := strings.TrimSpace(parameters["boundary"])
|
||||
if boundary == "" {
|
||||
return nil, "", errors.New("ims: multipart SMS has no boundary")
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(request.Body), boundary)
|
||||
for {
|
||||
part, nextErr := reader.NextRawPart()
|
||||
if errors.Is(nextErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if nextErr != nil {
|
||||
return nil, "", fmt.Errorf("ims: read multipart SMS: %w", nextErr)
|
||||
}
|
||||
partType, _, parseErr := mime.ParseMediaType(strings.TrimSpace(part.Header.Get("Content-Type")))
|
||||
if parseErr != nil || !strings.EqualFold(partType, smsContentType) {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
body, readErr := io.ReadAll(part)
|
||||
_ = part.Close()
|
||||
if readErr != nil {
|
||||
return nil, "", fmt.Errorf("ims: read multipart SMS payload: %w", readErr)
|
||||
}
|
||||
payload, decodeErr := decodeSMSTransfer(body, part.Header.Get("Content-Transfer-Encoding"))
|
||||
return payload, "multipart/mixed", decodeErr
|
||||
}
|
||||
return nil, "", errors.New("ims: multipart MESSAGE omitted application/vnd.3gpp.sms payload")
|
||||
}
|
||||
|
||||
func decodeSMSTransfer(body []byte, encoding string) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "binary", "8bit":
|
||||
return append([]byte(nil), body...), nil
|
||||
case "base64":
|
||||
decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, bytes.NewReader(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ims: decode base64 SMS payload: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
case "quoted-printable":
|
||||
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ims: decode quoted-printable SMS payload: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("ims: unsupported SMS Content-Transfer-Encoding %q", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) logInboundSMS(level slog.Level, message string, request *sipRequest, attributes ...any) {
|
||||
logger := slog.Default()
|
||||
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||
logger = session.provider.config.Logger
|
||||
}
|
||||
base := []any{"device_id", session.request.DeviceID}
|
||||
if request != nil {
|
||||
base = append(base,
|
||||
"call_id", strings.TrimSpace(request.value("Call-ID")),
|
||||
"content_type", strings.TrimSpace(request.value("Content-Type")),
|
||||
"body_bytes", len(request.Body),
|
||||
)
|
||||
}
|
||||
logger.Log(context.Background(), level, message, append(base, attributes...)...)
|
||||
}
|
||||
|
||||
func (session *Session) sendLoggedDeliveryReport(request *sipRequest, report []byte, reportType string) {
|
||||
if err := session.sendDeliveryReport(request, report); err != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS delivery report failed", request,
|
||||
"stage", "delivery_report", "report_type", reportType, "error", err)
|
||||
return
|
||||
}
|
||||
session.logInboundSMS(slog.LevelDebug, "IMS inbound SMS delivery report sent", request,
|
||||
"stage", "delivery_report", "report_type", reportType)
|
||||
}
|
||||
|
||||
func (session *Session) sendDeliveryReport(request *sipRequest, report []byte) error {
|
||||
target := firstURI(request.value("P-Asserted-Identity"))
|
||||
if target == "" {
|
||||
target = firstURI(request.value("From"))
|
||||
}
|
||||
if target == "" {
|
||||
return
|
||||
return errors.New("ims: SMS MESSAGE omitted a delivery-report target")
|
||||
}
|
||||
_, _ = session.sendSIPMessage(
|
||||
response, err := session.sendSIPMessage(
|
||||
context.Background(),
|
||||
target,
|
||||
report,
|
||||
strings.TrimSpace(request.value("Call-ID")),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("ims: SMS delivery report returned SIP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitRequest) (vowifi.SMSSubmitResult, error) {
|
||||
@@ -441,14 +662,21 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
||||
}
|
||||
smsc := strings.TrimSpace(session.request.Identity.SMSC)
|
||||
session.mu.Unlock()
|
||||
smscSource := "sim"
|
||||
if smsc == "" {
|
||||
smscSource = "sim_reader"
|
||||
reader, ok := session.provider.aka.(smsCenterReader)
|
||||
var readErr error
|
||||
if ok {
|
||||
smsc, readErr = reader.ReadSMSCenter(ctx, session.request.DeviceID)
|
||||
}
|
||||
if strings.TrimSpace(smsc) == "" {
|
||||
smsc = smsCenterForIdentity(session.provider.config, session.request.Identity)
|
||||
smscSource = "plmn_fallback"
|
||||
}
|
||||
if strings.TrimSpace(smsc) == "" {
|
||||
smsc = session.provider.config.SMSCenter
|
||||
smscSource = "configured_fallback"
|
||||
}
|
||||
if strings.TrimSpace(smsc) == "" {
|
||||
return vowifi.SMSSubmitResult{}, errors.Join(ErrSMSCUnavailable, readErr)
|
||||
@@ -471,6 +699,9 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
||||
SubmissionStatus: "pending",
|
||||
PartResults: make([]vowifi.SMSSubmitPart, 0, len(parts)),
|
||||
}
|
||||
session.logOutboundSMS(slog.LevelInfo, "IMS outbound SMS submission started",
|
||||
"stage", "prepare", "parts", len(parts), "smsc_source", smscSource,
|
||||
"recipient_type", smsRecipientType(parts[0].To))
|
||||
psi := "tel:" + normalizeE164(smsc)
|
||||
for _, part := range parts {
|
||||
reference := session.allocateRPReference()
|
||||
@@ -501,19 +732,63 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
||||
}
|
||||
result.PartResults = append(result.PartResults, partResult)
|
||||
if sendErr != nil {
|
||||
session.logOutboundSMS(slog.LevelWarn, "IMS outbound SMS submission failed",
|
||||
"stage", "sip_transaction", "part", part.Part,
|
||||
"rp_reference", int(reference), "error", sendErr)
|
||||
result.SubmissionStatus = "failed"
|
||||
return result, sendErr
|
||||
}
|
||||
if !partResult.Accepted {
|
||||
session.logOutboundSMS(slog.LevelWarn, "IMS outbound SMS was rejected",
|
||||
"stage", "sip_response", "part", part.Part,
|
||||
"rp_reference", int(reference), "sip_status", response.StatusCode)
|
||||
result.SubmissionStatus = "rejected"
|
||||
return result, fmt.Errorf("%w: SIP %d", ErrSMSRejected, response.StatusCode)
|
||||
}
|
||||
}
|
||||
result.AllPartsAccepted = true
|
||||
result.SubmissionStatus = "accepted_by_ims"
|
||||
session.logOutboundSMS(slog.LevelInfo, "IMS outbound SMS submission accepted",
|
||||
"stage", "sip_response", "parts", result.PartsAccepted)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
||||
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||
return configured
|
||||
}
|
||||
return strings.TrimSpace(vowifi.ResolveCarrierProfile(identity).SMSCenter)
|
||||
}
|
||||
|
||||
func smsRecipientType(recipient string) string {
|
||||
recipient = strings.TrimSpace(recipient)
|
||||
digits := strings.TrimPrefix(recipient, "+")
|
||||
switch {
|
||||
case strings.HasPrefix(recipient, "+"):
|
||||
return "international"
|
||||
case len(digits) <= 6:
|
||||
return "short_code"
|
||||
default:
|
||||
return "national"
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) logOutboundSMS(level slog.Level, message string, attributes ...any) {
|
||||
logger := slog.Default()
|
||||
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||
logger = session.provider.config.Logger
|
||||
}
|
||||
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
|
||||
base := []any{
|
||||
"device_id", session.request.DeviceID,
|
||||
"home_plmn", plmn,
|
||||
"transport", session.transport,
|
||||
"security", session.effectiveSecurityMode(),
|
||||
}
|
||||
logger.Log(context.Background(), level, message, append(base, attributes...)...)
|
||||
}
|
||||
|
||||
func (session *Session) allocateRPReference() byte {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
@@ -567,6 +842,8 @@ func (session *Session) sendSIPMessage(
|
||||
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
"Request-Disposition: no-fork",
|
||||
"Allow: MESSAGE",
|
||||
)
|
||||
if inReplyTo != "" {
|
||||
lines = append(lines, "In-Reply-To: "+inReplyTo)
|
||||
@@ -578,7 +855,23 @@ func (session *Session) sendSIPMessage(
|
||||
"", "",
|
||||
)
|
||||
request := append([]byte(strings.Join(lines, "\r\n")), body...)
|
||||
return session.exchangeRuntime(ctx, request, sipTransactionKey{callID: callID, cseq: cseq, method: "MESSAGE"})
|
||||
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE transaction started",
|
||||
"stage", "sip_send", "call_id", callID, "cseq", cseq,
|
||||
"body_bytes", len(body), "service_routes", len(serviceRoutes))
|
||||
response, exchangeErr := session.exchangeRuntime(
|
||||
ctx,
|
||||
request,
|
||||
sipTransactionKey{callID: callID, cseq: cseq, method: "MESSAGE"},
|
||||
)
|
||||
if exchangeErr != nil {
|
||||
session.logOutboundSMS(slog.LevelWarn, "IMS SIP MESSAGE transaction failed",
|
||||
"stage", "sip_transaction", "call_id", callID, "cseq", cseq, "error", exchangeErr)
|
||||
return response, exchangeErr
|
||||
}
|
||||
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE response received",
|
||||
"stage", "sip_response", "call_id", callID, "cseq", cseq,
|
||||
"sip_status", response.StatusCode)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func runtimeSecurityHeaders(active bool, verifyValue string) []string {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package ims
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -100,6 +103,127 @@ func TestRuntimeSecurityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSMSPayload(t *testing.T) {
|
||||
rpdu := []byte{0x01, 0x2a, 0x00, 0x00, 0x03, 0x04, 0x00, 0x00}
|
||||
tests := []struct {
|
||||
name string
|
||||
request *sipRequest
|
||||
wantSource string
|
||||
wantPayload []byte
|
||||
}{
|
||||
{
|
||||
name: "direct binary",
|
||||
request: &sipRequest{Headers: map[string][]string{
|
||||
"content-type": {smsContentType + "; charset=binary"},
|
||||
"content-transfer-encoding": {"binary"},
|
||||
}, Body: rpdu},
|
||||
wantSource: smsContentType,
|
||||
wantPayload: rpdu,
|
||||
},
|
||||
{
|
||||
name: "multipart base64",
|
||||
request: multipartSMSRequest(t, rpdu),
|
||||
wantSource: "multipart/mixed",
|
||||
wantPayload: rpdu,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
payload, source, err := extractSMSPayload(test.request)
|
||||
if err != nil {
|
||||
t.Fatalf("extractSMSPayload() error = %v", err)
|
||||
}
|
||||
if source != test.wantSource || !bytes.Equal(payload, test.wantPayload) {
|
||||
t.Fatalf("extractSMSPayload() = (%x, %q), want (%x, %q)",
|
||||
payload, source, test.wantPayload, test.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsSMSContentType(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{smsContentType, true},
|
||||
{"Application/Vnd.3gpp.Sms; charset=binary", true},
|
||||
{`multipart/mixed; boundary="vodafone-boundary"`, true},
|
||||
{"multipart/mixed", false},
|
||||
{"text/plain", false},
|
||||
} {
|
||||
if got := supportsSMSContentType(test.value); got != test.want {
|
||||
t.Errorf("supportsSMSContentType(%q) = %v, want %v", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
||||
config := Config{SMSCenterByPLMN: map[string]string{
|
||||
"23410": "+447802000332",
|
||||
"234010": "+447802000332",
|
||||
"23415": "+447785016005",
|
||||
}}
|
||||
for _, test := range []struct {
|
||||
mnc string
|
||||
want string
|
||||
}{
|
||||
{mnc: "10", want: "+447802000332"},
|
||||
{mnc: "010", want: "+447802000332"},
|
||||
{mnc: "15", want: "+447785016005"},
|
||||
{mnc: "30", want: ""},
|
||||
} {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
||||
if got := smsCenterForIdentity(config, identity); got != test.want {
|
||||
t.Errorf("smsCenterForIdentity(234/%s) = %q, want %q", test.mnc, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
mnc string
|
||||
want string
|
||||
}{
|
||||
{mnc: "10", want: "+447802000332"},
|
||||
{mnc: "15", want: "+447785016005"},
|
||||
{mnc: "30", want: ""},
|
||||
} {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
||||
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
|
||||
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.SetBoundary("vodafone-boundary"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Type", smsContentType)
|
||||
header.Set("Content-Transfer-Encoding", "base64")
|
||||
part, err := writer.CreatePart(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = part.Write([]byte(base64.StdEncoding.EncodeToString(payload))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &sipRequest{
|
||||
Headers: map[string][]string{
|
||||
"content-type": {`multipart/mixed; boundary="vodafone-boundary"`},
|
||||
},
|
||||
Body: body.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSendsSMSOverIMS(t *testing.T) {
|
||||
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
@@ -202,6 +326,24 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
||||
}
|
||||
rpdu := []byte{0x01, 0x2a, 0x00, 0x00, byte(len(tpdu))}
|
||||
rpdu = append(rpdu, tpdu...)
|
||||
var messageBody bytes.Buffer
|
||||
mimeWriter := multipart.NewWriter(&messageBody)
|
||||
if err = mimeWriter.SetBoundary("vodafone-delivery"); err != nil {
|
||||
return err
|
||||
}
|
||||
mimeHeader := make(textproto.MIMEHeader)
|
||||
mimeHeader.Set("Content-Type", smsContentType)
|
||||
mimeHeader.Set("Content-Transfer-Encoding", "binary")
|
||||
mimePart, createErr := mimeWriter.CreatePart(mimeHeader)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if _, err = mimePart.Write(rpdu); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = mimeWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
request := []byte(strings.Join([]string{
|
||||
"MESSAGE sip:[email protected] SIP/2.0",
|
||||
"Via: SIP/2.0/UDP " + listener.LocalAddr().String() + ";branch=z9hG4bKdeliver",
|
||||
@@ -210,10 +352,10 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
||||
"P-Asserted-Identity: <sip:[email protected]>",
|
||||
"Call-ID: network-deliver-1",
|
||||
"CSeq: 1 MESSAGE",
|
||||
"Content-Type: application/vnd.3gpp.sms",
|
||||
fmt.Sprintf("Content-Length: %d", len(rpdu)), "", "",
|
||||
`Content-Type: multipart/mixed; boundary="vodafone-delivery"`,
|
||||
fmt.Sprintf("Content-Length: %d", messageBody.Len()), "", "",
|
||||
}, "\r\n"))
|
||||
request = append(request, rpdu...)
|
||||
request = append(request, messageBody.Bytes()...)
|
||||
if _, err = listener.WriteToUDP(request, remote); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -292,12 +434,25 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
firstMessage := append([]byte(nil), packet[:count]...)
|
||||
firstRemote := remote.String()
|
||||
// Exercise the RFC SIP/UDP non-INVITE transaction retransmission path by
|
||||
// deliberately dropping the first MESSAGE request.
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remote.String() != firstRemote || !bytes.Equal(packet[:count], firstMessage) {
|
||||
return errors.New("outbound MESSAGE retransmission changed transaction bytes or source")
|
||||
}
|
||||
message, err := parseSIPPacket(packet[:count])
|
||||
if err != nil || message.Request == nil {
|
||||
return fmt.Errorf("outbound MESSAGE parse: %v", err)
|
||||
}
|
||||
if message.Request.Method != "MESSAGE" || message.Request.URI != "tel:+447785016005" ||
|
||||
strings.ToLower(message.Request.value("Content-Type")) != smsContentType {
|
||||
strings.ToLower(message.Request.value("Content-Type")) != smsContentType ||
|
||||
message.Request.value("Request-Disposition") != "no-fork" ||
|
||||
message.Request.value("Allow") != "MESSAGE" {
|
||||
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
|
||||
}
|
||||
rpdu, err := parseRPDU(message.Request.Body)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
type ATDeviceController interface {
|
||||
@@ -73,6 +74,28 @@ func (mapper ATMapper) ExecuteSensitiveAT(
|
||||
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
||||
}
|
||||
|
||||
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
|
||||
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
|
||||
// without issuing duplicate APDUs on every reconnect.
|
||||
func (mapper ATMapper) ReadSIMMetadata(ctx context.Context, configuredID string) (vowifi.SIMMetadata, error) {
|
||||
physicalID, err := mapper.resolve(ctx, configuredID)
|
||||
if err != nil {
|
||||
return vowifi.SIMMetadata{}, err
|
||||
}
|
||||
entry, err := mapper.Devices.Get(physicalID)
|
||||
if err != nil {
|
||||
return vowifi.SIMMetadata{}, err
|
||||
}
|
||||
if entry.Snapshot == nil {
|
||||
return vowifi.SIMMetadata{}, nil
|
||||
}
|
||||
return vowifi.SIMMetadata{
|
||||
SPN: strings.TrimSpace(entry.Snapshot.SPN),
|
||||
GID1: strings.TrimSpace(entry.Snapshot.GID1),
|
||||
GID2: strings.TrimSpace(entry.Snapshot.GID2),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (mapper ATMapper) resolve(
|
||||
ctx context.Context,
|
||||
configuredID string,
|
||||
|
||||
@@ -32,6 +32,7 @@ func (resolver ProxyResolver) Resolve(
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
var upstreamID string
|
||||
matchedCountryRule := false
|
||||
if iccid != "" {
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if err == nil {
|
||||
@@ -43,7 +44,10 @@ func (resolver ProxyResolver) Resolve(
|
||||
if upstreamID == "" {
|
||||
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
||||
if !found {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
country = strings.ToUpper(strings.TrimSpace(request.CountryCode))
|
||||
if len(country) != 2 {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
}
|
||||
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
||||
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
||||
@@ -53,6 +57,7 @@ func (resolver ProxyResolver) Resolve(
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
||||
}
|
||||
upstreamID = rule.UpstreamProxyID
|
||||
matchedCountryRule = true
|
||||
}
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
||||
if err != nil {
|
||||
@@ -64,12 +69,43 @@ func (resolver ProxyResolver) Resolve(
|
||||
)
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
if matchedCountryRule {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf(
|
||||
"upstream proxy %q for device %s is disabled",
|
||||
upstream.ID,
|
||||
deviceID,
|
||||
)
|
||||
}
|
||||
if matchedCountryRule && iccid != "" {
|
||||
created, bindErr := resolver.Store.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: deviceID,
|
||||
ICCID: iccid,
|
||||
ProfileName: iccid,
|
||||
UpstreamProxyID: upstream.ID,
|
||||
})
|
||||
if bindErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("materialize MCC proxy route for ICCID %s: %w", iccid, bindErr)
|
||||
}
|
||||
if !created {
|
||||
// Another request or an administrator may have created an explicit
|
||||
// binding after our first lookup. The persisted ICCID route wins.
|
||||
binding, bindingErr := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if bindingErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("reload proxy binding for ICCID %s: %w", iccid, bindingErr)
|
||||
}
|
||||
if binding.UpstreamProxyID != upstream.ID {
|
||||
upstream, err = resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("load materialized upstream proxy %q for device %s: %w", binding.UpstreamProxyID, deviceID, err)
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("upstream proxy %q for device %s is disabled", upstream.ID, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return vowifi.ProxyRoute{
|
||||
Mode: vowifi.ProxyModeSOCKS5,
|
||||
ID: upstream.ID,
|
||||
@@ -198,6 +234,8 @@ func (projector StateProjector) Save(
|
||||
"pure_airplane_policy": state.PureAirplanePolicy,
|
||||
"home_mcc": state.HomeMCC,
|
||||
"home_mnc": state.HomeMNC,
|
||||
"carrier_profile": state.CarrierProfile,
|
||||
"carrier_profile_from": state.CarrierProfileFrom,
|
||||
"warnings": state.Warnings,
|
||||
"cleanup_errors": state.CleanupErrors,
|
||||
"attempt": state.Attempt,
|
||||
|
||||
@@ -103,6 +103,141 @@ func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverCountryRuleWithDisabledProxyFallsBackDirect(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "disabled", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.Mode != vowifi.ProxyModeDirect {
|
||||
t.Fatalf("route = %#v, want direct for a disabled country default", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
{ID: "first", Name: "First", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "later", Name: "Later", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(ctx, proxy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "first", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
}
|
||||
resolver := ProxyResolver{Store: database}
|
||||
route, err := resolver.Resolve(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "first" {
|
||||
t.Fatalf("first route = %#v, want MCC default", route)
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, request.ICCID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.DeviceID != request.DeviceID || binding.UpstreamProxyID != "first" {
|
||||
t.Fatalf("materialized binding = %#v", binding)
|
||||
}
|
||||
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "later", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err = resolver.Resolve(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "first" {
|
||||
t.Fatalf("route after country rule edit = %#v, want durable ICCID binding", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, proxyID := range []string{"explicit", "default"} {
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: proxyID, Name: proxyID, Addr: "127.0.0.1:1080", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
iccid := "89441000400128014257"
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := database.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Automatic", UpstreamProxyID: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("default binding unexpectedly replaced an explicit binding")
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, iccid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.UpstreamProxyID != "explicit" || binding.ProfileName != "Manual" {
|
||||
t.Fatalf("binding = %#v, want explicit binding unchanged", binding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
||||
database := testStore(t)
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
@@ -216,13 +351,15 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
||||
}
|
||||
projector := StateProjector{Store: database}
|
||||
if err := projector.Save(context.Background(), vowifi.State{
|
||||
DeviceID: "ec25",
|
||||
Phase: vowifi.PhaseIMSReady,
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
TunnelName: "vocat-swu-ec25",
|
||||
DataplaneMode: "userspace",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
DeviceID: "ec25",
|
||||
Phase: vowifi.PhaseIMSReady,
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
TunnelName: "vocat-swu-ec25",
|
||||
DataplaneMode: "userspace",
|
||||
CarrierProfile: "vodafone-uk",
|
||||
CarrierProfileFrom: "hplmn",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,6 +377,13 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
||||
if tunnel["dataplane_mode"] != "userspace" {
|
||||
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
|
||||
}
|
||||
var extra map[string]any
|
||||
if err := json.Unmarshal(runtime.Extra, &extra); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if extra["carrier_profile"] != "vodafone-uk" || extra["carrier_profile_from"] != "hplmn" {
|
||||
t.Fatalf("carrier profile projection = %#v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NativeQMIController is implemented by device.Manager. It exposes only the
|
||||
// QMI UIM/DMS/NAS primitives needed by VoWiFi and keeps transport ownership in
|
||||
// the device layer.
|
||||
type NativeQMIController interface {
|
||||
ReadNativeQMIIdentity(context.Context, string) (iccid, imsi, imei, mcc, mnc string, err error)
|
||||
ProbeNativeQMIApplication(context.Context, string, string) ([]byte, string, error)
|
||||
AuthenticateNativeQMI(context.Context, string, []byte, []byte) ([]byte, error)
|
||||
NativeQMIRadioSnapshot(context.Context, string) (mode int, psAttached bool, err error)
|
||||
StopNativeQMICellularData(context.Context, string) error
|
||||
SetNativeQMIRadioOff(context.Context, string, bool) error
|
||||
}
|
||||
|
||||
type NativeQMIAdapter struct {
|
||||
controller NativeQMIController
|
||||
pureAirplanePolicy func(string) bool
|
||||
mu sync.Mutex
|
||||
bindings map[string]nativeQMIBinding
|
||||
}
|
||||
|
||||
type nativeQMIBinding struct {
|
||||
deviceID string
|
||||
iccid string
|
||||
imsi string
|
||||
aid []byte
|
||||
application string
|
||||
}
|
||||
|
||||
var _ SIMIdentityReader = (*NativeQMIAdapter)(nil)
|
||||
var _ PreferredAKAProvider = (*NativeQMIAdapter)(nil)
|
||||
var _ RadioController = (*NativeQMIAdapter)(nil)
|
||||
|
||||
func NewNativeQMIAdapter(controller NativeQMIController, purePolicy func(string) bool) (*NativeQMIAdapter, error) {
|
||||
if controller == nil {
|
||||
return nil, errors.New("vocat: native QMI controller is required")
|
||||
}
|
||||
return &NativeQMIAdapter{controller: controller, pureAirplanePolicy: purePolicy, bindings: make(map[string]nativeQMIBinding)}, nil
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if deviceID == "" {
|
||||
return SIMIdentity{}, errors.New("vocat: native QMI device ID is required")
|
||||
}
|
||||
iccid, imsi, imei, mcc, mnc, err := adapter.controller.ReadNativeQMIIdentity(ctx, deviceID)
|
||||
if err != nil {
|
||||
return SIMIdentity{}, err
|
||||
}
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
|
||||
if reader, ok := adapter.controller.(SIMMetadataReader); ok {
|
||||
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
}
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return SIMIdentity{}, err
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[identity.ICCID] = nativeQMIBinding{deviceID: deviceID, iccid: identity.ICCID, imsi: identity.IMSI}
|
||||
adapter.mu.Unlock()
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) binding(identity SIMIdentity) (nativeQMIBinding, error) {
|
||||
adapter.mu.Lock()
|
||||
binding, ok := adapter.bindings[strings.TrimSpace(identity.ICCID)]
|
||||
adapter.mu.Unlock()
|
||||
if !ok {
|
||||
return nativeQMIBinding{}, errors.New("vocat: native QMI SIM identity is not bound to a device")
|
||||
}
|
||||
if binding.imsi != strings.TrimSpace(identity.IMSI) {
|
||||
return nativeQMIBinding{}, ErrEC20IdentityChanged
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) verify(ctx context.Context, binding nativeQMIBinding) error {
|
||||
iccid, imsi, _, _, _, err := adapter.controller.ReadNativeQMIIdentity(ctx, binding.deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(iccid) != binding.iccid || strings.TrimSpace(imsi) != binding.imsi {
|
||||
return ErrEC20IdentityChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) {
|
||||
binding, err := adapter.binding(identity)
|
||||
if err != nil {
|
||||
return AKAEvidence{}, err
|
||||
}
|
||||
if err := adapter.verify(ctx, binding); err != nil {
|
||||
return AKAEvidence{}, err
|
||||
}
|
||||
aid, application, err := adapter.controller.ProbeNativeQMIApplication(ctx, binding.deviceID, "")
|
||||
if err != nil {
|
||||
return AKAEvidence{}, fmt.Errorf("%w: %v", ErrEC20ApplicationAbsent, err)
|
||||
}
|
||||
binding.aid, binding.application = append([]byte(nil), aid...), application
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[binding.iccid] = binding
|
||||
adapter.mu.Unlock()
|
||||
return AKAEvidence{Ready: true, Application: application}, nil
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) {
|
||||
return adapter.AuthenticateWithPreference(ctx, identity, challenge, "")
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context, identity SIMIdentity, challenge AKAChallenge, preference string) (AKAResult, error) {
|
||||
binding, err := adapter.binding(identity)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
strictISIM := strings.EqualFold(strings.TrimSpace(preference), "isim_strict")
|
||||
if len(binding.aid) == 0 || (strictISIM && binding.application != "ISIM") {
|
||||
aid, application, probeErr := adapter.controller.ProbeNativeQMIApplication(ctx, binding.deviceID, preference)
|
||||
if probeErr != nil {
|
||||
return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20ApplicationAbsent, probeErr)
|
||||
}
|
||||
binding.aid, binding.application = append([]byte(nil), aid...), application
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[binding.iccid] = binding
|
||||
adapter.mu.Unlock()
|
||||
}
|
||||
if strictISIM && binding.application != "ISIM" {
|
||||
return AKAResult{}, ErrEC20ApplicationAbsent
|
||||
}
|
||||
if err := adapter.verify(ctx, binding); err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge))
|
||||
if err != nil {
|
||||
return AKAResult{}, ErrEC20AKACommand
|
||||
}
|
||||
return parseUSIMAuthenticateResponse(raw)
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) Snapshot(ctx context.Context, deviceID string) (RadioSnapshot, error) {
|
||||
mode, attached, err := adapter.controller.NativeQMIRadioSnapshot(ctx, deviceID)
|
||||
if err != nil {
|
||||
return RadioSnapshot{}, err
|
||||
}
|
||||
pure := false
|
||||
if adapter.pureAirplanePolicy != nil {
|
||||
pure = adapter.pureAirplanePolicy(deviceID)
|
||||
}
|
||||
return RadioSnapshot{CellularDataEnabled: attached, OperatingMode: mode, PureAirplanePolicy: pure}, nil
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) StopCellularData(ctx context.Context, deviceID string) error {
|
||||
return adapter.controller.StopNativeQMICellularData(ctx, deviceID)
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) EnterVoWiFiRFOff(ctx context.Context, deviceID string) error {
|
||||
return adapter.controller.SetNativeQMIRadioOff(ctx, deviceID, true)
|
||||
}
|
||||
|
||||
func (adapter *NativeQMIAdapter) Restore(ctx context.Context, deviceID string, snapshot RadioSnapshot) error {
|
||||
// A user VoWiFi policy is fail-closed. Otherwise restore whether the modem
|
||||
// was online, never a packet context that was detached for VoWiFi.
|
||||
off := snapshot.PureAirplanePolicy || snapshot.OperatingMode != 1
|
||||
return adapter.controller.SetNativeQMIRadioOff(ctx, deviceID, off)
|
||||
}
|
||||
@@ -241,6 +241,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
||||
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
|
||||
}
|
||||
}
|
||||
carrierProfile := ResolveCarrierProfile(identity)
|
||||
orchestrator.mutate(func(state *State) {
|
||||
state.Phase = PhaseSIMReady
|
||||
state.ICCID = strings.TrimSpace(identity.ICCID)
|
||||
@@ -248,6 +249,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
||||
state.SIMReady = true
|
||||
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
||||
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
||||
state.CarrierProfile = carrierProfile.ID
|
||||
state.CarrierProfileFrom = carrierProfile.MatchSource
|
||||
state.LastReason = "sim_and_aka_ready"
|
||||
})
|
||||
|
||||
@@ -645,8 +648,12 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
|
||||
}
|
||||
return strings.ToLower(configured), nil
|
||||
}
|
||||
if IsATT310280(identity) {
|
||||
return att310280EPDG, nil
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.EPDG != "" {
|
||||
return profile.EPDG, nil
|
||||
}
|
||||
if profile.RouteMCC != "" {
|
||||
return standardEPDGHostname(profile.RouteMCC, profile.RouteMNC), nil
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -53,7 +53,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
mncLength := identity.MNCLength
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC, SPN: identity.SPN}), nil
|
||||
}
|
||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
return applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||
SMSC: identity.SMSC,
|
||||
SMSC: identity.SMSC, SPN: identity.SPN,
|
||||
}), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ type Manager struct {
|
||||
|
||||
type entry struct {
|
||||
orchestrator *vowifi.Orchestrator
|
||||
maintenance bool
|
||||
busy bool
|
||||
reconnectPending bool
|
||||
disablePending bool
|
||||
@@ -66,6 +67,36 @@ type entry struct {
|
||||
stopWatch func()
|
||||
}
|
||||
|
||||
// BeginMaintenance temporarily suppresses background enable requests while a
|
||||
// caller performs an exclusive SIM operation such as switching eSIM profiles.
|
||||
// Disable requests remain allowed so the current runtime can release QMI/UIM.
|
||||
func (manager *Manager) BeginMaintenance(deviceID string) error {
|
||||
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
item := manager.entries[deviceID]
|
||||
if item == nil {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
item.maintenance = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndMaintenance re-enables ordinary desired-state reconciliation. The caller
|
||||
// then applies the newly active profile's persisted policy.
|
||||
func (manager *Manager) EndMaintenance(deviceID string) {
|
||||
manager.mu.Lock()
|
||||
if item := manager.entries[deviceID]; item != nil {
|
||||
item.maintenance = false
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func New(options Options) *Manager {
|
||||
if options.Logger == nil {
|
||||
options.Logger = slog.Default()
|
||||
@@ -210,6 +241,11 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St
|
||||
}
|
||||
manager.mu.Lock()
|
||||
item := manager.entries[deviceID]
|
||||
if item.maintenance && enabled {
|
||||
state := item.orchestrator.State()
|
||||
manager.mu.Unlock()
|
||||
return state, nil
|
||||
}
|
||||
item.desiredEnabled = enabled
|
||||
if item.busy {
|
||||
manager.logger.Info(
|
||||
|
||||
@@ -96,6 +96,8 @@ type State struct {
|
||||
PureAirplanePolicy bool `json:"pure_airplane_policy"`
|
||||
HomeMCC string `json:"home_mcc,omitempty"`
|
||||
HomeMNC string `json:"home_mnc,omitempty"`
|
||||
CarrierProfile string `json:"carrier_profile,omitempty"`
|
||||
CarrierProfileFrom string `json:"carrier_profile_from,omitempty"`
|
||||
EPDG string `json:"epdg,omitempty"`
|
||||
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
|
||||
ProxyID string `json:"proxy_id,omitempty"`
|
||||
@@ -137,6 +139,9 @@ type SIMIdentity struct {
|
||||
HomeMCC string
|
||||
HomeMNC string
|
||||
HomeCountryCode string
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
EPDG string
|
||||
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
|
||||
// RP-DATA. It is optional during identity discovery, but IMS submission
|
||||
@@ -304,6 +309,22 @@ type SIMIdentityReader interface {
|
||||
ReadIdentity(context.Context, string) (SIMIdentity, error)
|
||||
}
|
||||
|
||||
// SIMMetadata contains optional, non-secret carrier selectors stored by the
|
||||
// UICC. They improve MVNO matching but are never required for AKA or exposed in
|
||||
// the public runtime state.
|
||||
type SIMMetadata struct {
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
}
|
||||
|
||||
// SIMMetadataReader is an optional companion implemented by device mappers
|
||||
// that already cache EF_SPN and EF_GID1/2. Identity readers degrade to PLMN,
|
||||
// IMSI and ICCID matching when it is unavailable.
|
||||
type SIMMetadataReader interface {
|
||||
ReadSIMMetadata(context.Context, string) (SIMMetadata, error)
|
||||
}
|
||||
|
||||
// SMSCenterReader optionally supplies the SIM-configured service-centre
|
||||
// address needed for mobile-originated SMS over IMS.
|
||||
type SMSCenterReader interface {
|
||||
|
||||
+13
-1
@@ -298,6 +298,18 @@ detect_arch() {
|
||||
|
||||
# --- Download + verify -------------------------------------------------------
|
||||
VOCAT_TMP=""
|
||||
|
||||
# curl transfer options for the binary download. -f makes curl fail on HTTP
|
||||
# errors and -L follows the release-asset redirect. On an interactive terminal
|
||||
# we show a single-line progress bar so a multi-megabyte download gives visible
|
||||
# feedback; otherwise (piped, cron, systemd) we stay quiet but still surface
|
||||
# errors via -S.
|
||||
if [ -t 2 ]; then
|
||||
CURL_DL_OPTS=(-fSL --progress-bar)
|
||||
else
|
||||
CURL_DL_OPTS=(-fsSL)
|
||||
fi
|
||||
|
||||
download_and_verify() {
|
||||
VOCAT_TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$VOCAT_TMP"' EXIT
|
||||
@@ -307,7 +319,7 @@ download_and_verify() {
|
||||
asset="vocat-linux-${ARCH_FALLBACK}"
|
||||
fi
|
||||
msg "下载 $asset ..." "Downloading $asset ..."
|
||||
curl -fsSL -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
|
||||
curl "${CURL_DL_OPTS[@]}" -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
|
||||
curl -fsSL -o "${VOCAT_TMP}/SHA256SUMS" "${base}/SHA256SUMS" || die "下载 SHA256SUMS 失败。" "Failed to download SHA256SUMS."
|
||||
|
||||
local expected actual
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmbeddedDistributionContainsIndex(t *testing.T) {
|
||||
index, err := fs.ReadFile(Dist, "index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded index.html: %v", err)
|
||||
}
|
||||
if len(index) == 0 {
|
||||
t.Fatal("embedded index.html is empty")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+38
-2
@@ -83,7 +83,29 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
async function refreshCSRFToken(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) notifyUnauthorized();
|
||||
return false;
|
||||
}
|
||||
const payload = await response.json() as { data?: { csrf_token?: string } };
|
||||
const token = payload?.data?.csrf_token;
|
||||
if (!token) return false;
|
||||
sessionStorage.setItem(CSRF_KEY, token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: boolean): Promise<T> {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers);
|
||||
const formBody = typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||
@@ -116,7 +138,6 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
||||
: { message: await response.text() };
|
||||
const normalized = camelize<Record<string, unknown>>(payload);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) notifyUnauthorized();
|
||||
const nested = normalized.error;
|
||||
const detail = nested && typeof nested === "object"
|
||||
? {
|
||||
@@ -124,11 +145,26 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
||||
requestId: (normalized.requestId as string | undefined) || (nested as ApiErrorBody).requestId,
|
||||
}
|
||||
: normalized as ApiErrorBody;
|
||||
if (
|
||||
retryCSRF &&
|
||||
isMutation(method) &&
|
||||
response.status === 403 &&
|
||||
detail.code === "invalid_csrf"
|
||||
) {
|
||||
if (await refreshCSRFToken()) return requestAPI<T>(path, options, false);
|
||||
notifyUnauthorized();
|
||||
} else if (response.status === 401) {
|
||||
notifyUnauthorized();
|
||||
}
|
||||
throw new ApiError(response.status, detail);
|
||||
}
|
||||
return (Object.prototype.hasOwnProperty.call(normalized, "data") ? normalized.data : normalized) as T;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
return requestAPI<T>(path, options, true);
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const result = await api<LoginResponse & { user?: { username?: string } }>("/auth/login", {
|
||||
method: "POST",
|
||||
|
||||
@@ -14,11 +14,12 @@ export interface CardPolicyPanelProps {
|
||||
iccid?: string;
|
||||
policy: CardPolicy | null;
|
||||
deviceOnline: boolean;
|
||||
onPolicyChanged: () => void | Promise<void>;
|
||||
onPolicyChanged: () => void | Promise<void>;
|
||||
wifiCallingOnly?: boolean;
|
||||
vowifiUnsupported?: boolean;
|
||||
}
|
||||
|
||||
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) {
|
||||
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false, vowifiUnsupported = false }: CardPolicyPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const operable = deviceOnline && !!iccid;
|
||||
const currentPolicy = policy?.iccid === iccid ? policy : null;
|
||||
@@ -120,7 +121,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<PolicySwitchCard
|
||||
{!vowifiUnsupported ? <PolicySwitchCard
|
||||
title="VoWiFi"
|
||||
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
|
||||
tone="orange"
|
||||
@@ -129,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
pending={toggles.vowifiPending}
|
||||
failed={toggles.vowifiFailed}
|
||||
onToggle={toggles.onVoWiFiToggle}
|
||||
/>
|
||||
/> : null}
|
||||
{!wifiCallingOnly ? <PolicySwitchCard
|
||||
title={t("飞行模式")}
|
||||
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { SaveRegular } from "@fluentui/react-icons";
|
||||
import { ArrowSyncRegular, SaveRegular } from "@fluentui/react-icons";
|
||||
import { cx } from "../../lib/utils";
|
||||
import { Button, Input, Modal, Select, Spinner, Tag } from "../ui";
|
||||
import { isQmiControl } from "./shared";
|
||||
@@ -17,6 +17,7 @@ export interface DeviceAddDialogProps {
|
||||
addConfig: AddDeviceForm;
|
||||
addSaving: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
onSelectDevice: (d: DiscoveredDevice) => void;
|
||||
onConfigChange: (next: AddDeviceForm) => void;
|
||||
onSave: () => void;
|
||||
@@ -85,7 +86,12 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mb-3 text-sm text-gray-500">{t("选择一个“未配置”的设备,系统将自动填充 AT 端口与识别信息。")}</div>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-gray-500">{t("选择一个“未配置”的设备,系统将自动填充 AT 端口与识别信息。")}</div>
|
||||
<Button size="small" loading={props.discovering} onClick={props.onRefresh} icon={<ArrowSyncRegular />}>
|
||||
{t("刷新设备")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[260px] space-y-2 overflow-auto pr-1">
|
||||
{props.discovering ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-gray-400">
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface DeviceDetailHeaderProps {
|
||||
onRebootModem: () => void;
|
||||
onOpenSms: () => void;
|
||||
wifiCallingOnly?: boolean;
|
||||
modemControlOnly?: boolean;
|
||||
}
|
||||
|
||||
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
||||
@@ -59,12 +60,12 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
|
||||
{!props.wifiCallingOnly && !props.modemControlOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
|
||||
{t("重启模组")}
|
||||
</Button> : null}
|
||||
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
|
||||
{!props.modemControlOnly ? <Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
|
||||
{t("短信")}
|
||||
</Button>
|
||||
</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
||||
</div>
|
||||
) : null}
|
||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
||||
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { SearchRegular } from "@fluentui/react-icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Country, CountryRule, UpstreamProxy } from "../../types";
|
||||
import { Button, EmptyState, Input, Modal, Select } from "../ui";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface CountryRulesDialogProps {
|
||||
open: boolean;
|
||||
proxies: UpstreamProxy[];
|
||||
countries: Country[];
|
||||
rules: CountryRule[];
|
||||
busy: boolean;
|
||||
onSave: (assignments: Record<string, string>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CountryRulesDialog(props: CountryRulesDialogProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const { open, proxies, countries, rules, busy, onSave, onClose } = props;
|
||||
const [query, setQuery] = useState("");
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
const regionNames = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [lang]);
|
||||
const countryLabel = (country: Country) => regionNames?.of(country.countryCode) || country.countryName || country.countryCode;
|
||||
const proxyOptions = useMemo(() => [
|
||||
{ value: "", label: t("直连") },
|
||||
...proxies.map((proxy) => ({
|
||||
value: proxy.id,
|
||||
label: proxy.enabled ? (proxy.name || proxy.id) : `${proxy.name || proxy.id}(${t("已禁用")})`,
|
||||
disabled: !proxy.enabled,
|
||||
})),
|
||||
], [proxies, t, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setAssignments({});
|
||||
return;
|
||||
}
|
||||
setAssignments(Object.fromEntries(rules.filter((rule) => rule.enabled).map((rule) => [rule.countryCode, rule.upstreamProxyId])));
|
||||
// Sample rules only when opening. Polling must not discard in-progress edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return [...countries]
|
||||
.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b), lang === "zh" ? "zh-CN" : "en"))
|
||||
.filter((country) => {
|
||||
if (!needle) return true;
|
||||
return [country.countryCode, country.countryName, countryLabel(country), ...country.mccs]
|
||||
.some((value) => String(value || "").toLocaleLowerCase().includes(needle));
|
||||
});
|
||||
}, [countries, query, lang, regionNames]);
|
||||
|
||||
const configuredCount = Object.values(assignments).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("MCC 国家规则")}
|
||||
width="max-w-5xl"
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>{t("取消")}</Button>
|
||||
<Button variant="primary" loading={busy} onClick={() => onSave(assignments)}>{t("保存规则")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
|
||||
{t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs text-gray-500">{configuredCount} {t("个国家规则")}</div>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("搜索国家、地区代码或 MCC")}
|
||||
prefix={<SearchRegular />}
|
||||
className="w-full sm:w-72"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-white/10">
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
<table className="w-full min-w-[680px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-[#202027]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("国家 / 地区")}</th>
|
||||
<th className="px-4 py-3">MCC</th>
|
||||
<th className="w-72 px-4 py-3">{t("规则")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{filtered.map((country) => (
|
||||
<tr key={country.countryCode} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium">{countryLabel(country)}</span>
|
||||
<span className="ml-2 font-mono text-xs text-gray-400">{country.countryCode}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{country.mccs.join(", ")}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Select
|
||||
value={assignments[country.countryCode] || ""}
|
||||
options={proxyOptions}
|
||||
disabled={busy}
|
||||
onChange={(value) => setAssignments((current) => ({ ...current, [country.countryCode]: value }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filtered.length === 0 ? <EmptyState title={t("没有匹配的国家或 MCC")} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular, PauseRegular, PlayRegular } from "@fluentui/react-icons";
|
||||
import type { UpstreamProxy } from "../../types";
|
||||
import { Button, Tag } from "../ui";
|
||||
import type { LoadError, UpstreamRow } from "./shared";
|
||||
@@ -12,9 +12,11 @@ export interface UpstreamSectionProps {
|
||||
onEdit: (proxy: UpstreamProxy) => void;
|
||||
onDelete: (proxy: UpstreamProxy) => void;
|
||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||
onToggle: (proxy: UpstreamProxy) => void;
|
||||
toggleBusyId?: string;
|
||||
}
|
||||
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings, onToggle, toggleBusyId }: UpstreamSectionProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="ui-card overflow-hidden">
|
||||
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("名称")}</th>
|
||||
<th className="px-4 py-3">{t("协议")}</th>
|
||||
<th className="px-4 py-3">{t("地址")}</th>
|
||||
<th className="px-4 py-3">{t("鉴权")}</th>
|
||||
<th className="px-4 py-3">{t("状态")}</th>
|
||||
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
|
||||
<th className="px-4 py-3">{t("国家规则")}</th>
|
||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
|
||||
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
|
||||
<td className="px-4 py-3">{row.username || t("无")}</td>
|
||||
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
|
||||
</div>
|
||||
{row.bindingCount}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.countryNames.length ? (
|
||||
<div className="flex max-w-sm flex-wrap gap-1">
|
||||
{row.countryNames.map((countryName) => <Tag key={countryName} type="primary">{countryName}</Tag>)}
|
||||
</div>
|
||||
) : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant={row.enabled ? "warning" : "success"}
|
||||
plain
|
||||
icon={row.enabled ? <PauseRegular /> : <PlayRegular />}
|
||||
loading={toggleBusyId === row.id}
|
||||
onClick={() => onToggle(row)}
|
||||
>{row.enabled ? t("禁用") : t("启用")}</Button>
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
||||
@@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<GlobeRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无上游代理")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface UpstreamProbeResult {
|
||||
|
||||
export interface UpstreamRow extends UpstreamProxy {
|
||||
bindingCount: number;
|
||||
countryNames: string[];
|
||||
}
|
||||
|
||||
export function ipv6Hint(): string {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface AutomaticTaskProfileGroup {
|
||||
aidHex?: string;
|
||||
profiles?: Array<{
|
||||
iccid: string;
|
||||
name?: string;
|
||||
serviceProviderName?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AutomaticTaskProfileOption {
|
||||
iccid: string;
|
||||
aidHex: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AutomaticTaskProfileRequestGuard {
|
||||
begin: () => number;
|
||||
invalidate: () => void;
|
||||
isCurrent: (requestID: number) => boolean;
|
||||
}
|
||||
|
||||
export function createAutomaticTaskProfileRequestGuard(): AutomaticTaskProfileRequestGuard {
|
||||
let latestRequestID = 0;
|
||||
return {
|
||||
begin: () => ++latestRequestID,
|
||||
invalidate: () => { latestRequestID += 1; },
|
||||
isCurrent: (requestID) => requestID === latestRequestID,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutomaticTaskProfileOptions(
|
||||
groups: AutomaticTaskProfileGroup[],
|
||||
currentICCID: string,
|
||||
currentSIMLabel: string,
|
||||
): AutomaticTaskProfileOption[] {
|
||||
const options = groups.flatMap((group, groupIndex) =>
|
||||
(group.profiles || []).map((profile) => ({
|
||||
iccid: profile.iccid,
|
||||
aidHex: group.aidHex || "",
|
||||
label: `${profile.name || profile.serviceProviderName || `Profile ${groupIndex + 1}`} · ${profile.iccid}`,
|
||||
})),
|
||||
);
|
||||
const iccid = currentICCID.trim();
|
||||
if (iccid && !options.some((option) => option.iccid.trim() === iccid)) {
|
||||
options.push({ iccid, aidHex: "", label: `${currentSIMLabel} · ${iccid}` });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function selectAutomaticTaskProfileOption(
|
||||
options: AutomaticTaskProfileOption[],
|
||||
requestedICCID: string,
|
||||
): AutomaticTaskProfileOption | undefined {
|
||||
const iccid = requestedICCID.trim();
|
||||
if (iccid) return options.find((option) => option.iccid.trim() === iccid);
|
||||
return options[0];
|
||||
}
|
||||
@@ -178,8 +178,11 @@ export const EN_DICT: Record<string, string> = {
|
||||
自动任务: "Automatic Tasks",
|
||||
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Switch to a selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
|
||||
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Switch to a selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue",
|
||||
"按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Use the selected SIM or switch to the selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
|
||||
"按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Use the selected SIM or switch to the selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue",
|
||||
添加任务: "Add Task",
|
||||
"设备 / Profile": "Device / Profile",
|
||||
"设备 / SIM / Profile": "Device / SIM / Profile",
|
||||
执行环境: "Environment",
|
||||
周期: "Schedule",
|
||||
下次执行: "Next Run",
|
||||
@@ -194,6 +197,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
立即执行: "Run Now",
|
||||
暂无自动任务: "No automatic tasks",
|
||||
"添加任务后,系统会按设备排队并在执行前校验目标 Profile": "Tasks are queued per device and the target profile is verified before execution",
|
||||
"添加任务后,系统会按设备排队并在执行前校验目标 SIM / Profile": "Tasks are queued per device and the target SIM or profile is verified before execution",
|
||||
最近执行记录: "Recent Runs",
|
||||
排队时间: "Queued At",
|
||||
尝试次数: "Attempts",
|
||||
@@ -242,6 +246,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
请输入号码: "Enter a number",
|
||||
"请选择 eSIM Profile": "Select an eSIM profile",
|
||||
"请选择 Profile": "Select a profile",
|
||||
"请选择 SIM 卡或 eSIM Profile": "Select a SIM card or eSIM profile",
|
||||
"请选择 SIM / Profile": "Select a SIM or profile",
|
||||
请选择设备: "Select a device",
|
||||
"确定删除这个自动任务吗?": "Delete this automatic task?",
|
||||
任务: "Task",
|
||||
@@ -774,6 +780,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"改动将在此卡激活后生效": "Changes take effect once this card is activated",
|
||||
"数据未开启": "Data is off",
|
||||
"数据平面": "Data Plane",
|
||||
"运营商配置": "Carrier Profile",
|
||||
"匹配依据": "Profile Match",
|
||||
"方向": "Direction",
|
||||
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
||||
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
||||
@@ -993,11 +1001,19 @@ export const EN_DICT: Record<string, string> = {
|
||||
最新: "Latest",
|
||||
加载更多: "Load More",
|
||||
刷新: "Refresh",
|
||||
"刷新设备": "Refresh Devices",
|
||||
|
||||
// ---- 代理 / 国家规则(i18n 补充) ----
|
||||
"绑定:": "Bound:",
|
||||
"鉴权:": "Auth:",
|
||||
国家规则: "Country Rules",
|
||||
"MCC 国家规则": "MCC Country Rules",
|
||||
规则: "Rule",
|
||||
代理已启用: "Proxy enabled",
|
||||
代理已禁用: "Proxy disabled",
|
||||
切换代理状态失败: "Failed to change proxy status",
|
||||
"代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连":
|
||||
"Proxy disabled. Explicit ICCID bindings stop using this route without falling back to direct; MCC defaults not yet materialized fall back to direct.",
|
||||
新增代理: "Add Proxy",
|
||||
新增实例: "Add Instance",
|
||||
删除规则: "Delete Rule",
|
||||
@@ -1006,6 +1022,22 @@ export const EN_DICT: Record<string, string> = {
|
||||
"UDP 中继地址:": "UDP Relay Address: ",
|
||||
"规则按 SIM 归属 MCC 解析国家。例如 US 会覆盖 MCC 310/311/312/313/314/315/316 等表内分组;没有配置规则的国家默认直连。需要重启 VoWiFi 生效。":
|
||||
"Country is resolved from the SIM home MCC. For example, US covers the listed MCC 310/311/312/313/314/315/316 groups; countries without a rule use direct connection. Restart VoWiFi to take effect.",
|
||||
"未绑定 ICCID 的卡会按 SIM 归属 MCC 匹配国家规则;首次命中后会生成独立的 ICCID 绑定。ICCID 绑定优先,未命中任何规则时直连。":
|
||||
"A SIM without an ICCID binding uses the country rule matching its home MCC. The first match creates an independent ICCID binding. ICCID bindings take priority; otherwise unmatched SIMs connect directly.",
|
||||
"为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。":
|
||||
"Choose a proxy for each country's MCC. Unconfigured MCCs connect directly. Existing ICCID bindings always take priority, and the first country-rule match creates an independent ICCID binding.",
|
||||
"同一国家只能属于一个代理;选择已分配的国家会将它迁移到当前代理。":
|
||||
"Each country can belong to only one proxy. Selecting a country assigned elsewhere moves it to this proxy.",
|
||||
"搜索国家、地区代码或 MCC": "Search country, region code, or MCC",
|
||||
"国家 / 地区": "Country / Region",
|
||||
当前规则: "Current Rule",
|
||||
当前代理: "This Proxy",
|
||||
直连: "Direct",
|
||||
"没有匹配的国家或 MCC": "No matching country or MCC",
|
||||
"管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定":
|
||||
"Manage VoWiFi upstream proxies, MCC country rules, and physical SIM / eSIM profile bindings",
|
||||
"点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。":
|
||||
"Create a SOCKS5 upstream proxy, then configure country rules or ICCID bindings. Unmatched SIMs connect directly by default.",
|
||||
"VoWiFi 通过此 Socks5 代理连接运营商,实现跨区域本地 VoWiFi。":
|
||||
"VoWiFi connects to the carrier through this Socks5 proxy, enabling cross-region local VoWiFi. ",
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
message,
|
||||
} from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import {
|
||||
buildAutomaticTaskProfileOptions,
|
||||
createAutomaticTaskProfileRequestGuard,
|
||||
selectAutomaticTaskProfileOption,
|
||||
} from "../lib/automaticTaskProfiles";
|
||||
|
||||
type TaskType = "sms" | "call" | "public_ip";
|
||||
type TaskEnvironment = "vowifi" | "cellular";
|
||||
@@ -130,6 +135,10 @@ function formatDateTime(value?: string) {
|
||||
return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString();
|
||||
}
|
||||
|
||||
function currentDeviceICCID(device?: DeviceListItem) {
|
||||
return String(device?.modem?.iccid || device?.vowifiRuntime?.iccid || "").trim();
|
||||
}
|
||||
|
||||
const fieldLabel = "mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-200";
|
||||
|
||||
export default function AutomaticTasksPage() {
|
||||
@@ -152,6 +161,7 @@ export default function AutomaticTasksPage() {
|
||||
// is actually looking at instead of snapping back to page 1 on every tick.
|
||||
const runsPageRef = useRef(1);
|
||||
const runsPageSizeRef = useRef(20);
|
||||
const profileRequestGuardRef = useRef(createAutomaticTaskProfileRequestGuard());
|
||||
|
||||
const load = useCallback(async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
@@ -209,6 +219,8 @@ export default function AutomaticTasksPage() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, reloadRuns]);
|
||||
|
||||
useEffect(() => () => profileRequestGuardRef.current.invalidate(), []);
|
||||
|
||||
function changeRunsPage(page: number) {
|
||||
runsPageRef.current = page;
|
||||
setRunsPage(page);
|
||||
@@ -223,37 +235,53 @@ export default function AutomaticTasksPage() {
|
||||
void fetchRuns(1, pageSize);
|
||||
}
|
||||
|
||||
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => {
|
||||
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "", currentICCID = "") => {
|
||||
if (!deviceId) {
|
||||
profileRequestGuardRef.current.invalidate();
|
||||
setProfiles([]);
|
||||
setProfileLoading(false);
|
||||
return;
|
||||
}
|
||||
const requestID = profileRequestGuardRef.current.begin();
|
||||
setProfiles([]);
|
||||
if (!deviceId) return;
|
||||
setProfileLoading(true);
|
||||
let groups: EsimProfileGroup[] = [];
|
||||
let inventoryError: unknown;
|
||||
try {
|
||||
const data = await api<{ profiles?: EsimProfileGroup[] }>(`/devices/${encodeURIComponent(deviceId)}/esim`);
|
||||
const options = (data.profiles || []).flatMap((group, groupIndex) =>
|
||||
(group.profiles || []).map((profile) => ({
|
||||
iccid: profile.iccid,
|
||||
aidHex: group.aidHex || "",
|
||||
label: `${profile.name || profile.serviceProviderName || `Profile ${groupIndex + 1}`} · ${profile.iccid}`,
|
||||
})),
|
||||
);
|
||||
setProfiles(options);
|
||||
setForm((current) => {
|
||||
if (current.deviceId !== deviceId) return current;
|
||||
const selected = options.find((item) => item.iccid === (keepICCID || current.profileIccid)) || options[0];
|
||||
return selected ? { ...current, profileIccid: selected.iccid, profileAid: selected.aidHex } : current;
|
||||
});
|
||||
groups = data.profiles || [];
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
inventoryError = error;
|
||||
}
|
||||
}, []);
|
||||
if (!profileRequestGuardRef.current.isCurrent(requestID)) return;
|
||||
const options = buildAutomaticTaskProfileOptions(groups, currentICCID, t("当前 SIM 卡"));
|
||||
const requestedICCID = keepICCID.trim();
|
||||
const requestedUnavailable = requestedICCID !== "" &&
|
||||
!options.some((option) => option.iccid.trim() === requestedICCID);
|
||||
if (inventoryError && (options.length === 0 || requestedUnavailable)) {
|
||||
message.error(apiMessage(inventoryError));
|
||||
}
|
||||
setProfiles(options);
|
||||
setForm((current) => {
|
||||
if (current.deviceId !== deviceId) return current;
|
||||
const selected = selectAutomaticTaskProfileOption(options, requestedICCID);
|
||||
return selected ? { ...current, profileIccid: selected.iccid, profileAid: selected.aidHex } : current;
|
||||
});
|
||||
setProfileLoading(false);
|
||||
}, [t]);
|
||||
|
||||
function closeEditor() {
|
||||
profileRequestGuardRef.current.invalidate();
|
||||
setProfileLoading(false);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const deviceByID = useMemo(() => new Map(devices.map((device) => [device.id, device])), [devices]);
|
||||
const taskByID = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
|
||||
function edit(task?: AutomaticTask) {
|
||||
const deviceId = task?.deviceId || devices[0]?.id || "";
|
||||
const selectedDevice = devices.find((device) => device.id === deviceId);
|
||||
let next = task ? {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
@@ -272,7 +300,7 @@ export default function AutomaticTasksPage() {
|
||||
message: task.payload?.message || "",
|
||||
durationSeconds: task.payload?.durationSeconds || 30,
|
||||
} : emptyForm(deviceId);
|
||||
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
|
||||
if (selectedDevice?.deviceType === "usb_sim_reader") {
|
||||
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
|
||||
}
|
||||
if (!advancedTasksAvailable && (next.taskType === "public_ip" || next.environment === "cellular")) {
|
||||
@@ -280,17 +308,18 @@ export default function AutomaticTasksPage() {
|
||||
}
|
||||
setForm(next);
|
||||
setOpen(true);
|
||||
void loadProfiles(deviceId, next.profileIccid);
|
||||
void loadProfiles(deviceId, next.profileIccid, currentDeviceICCID(selectedDevice));
|
||||
}
|
||||
|
||||
function chooseDevice(deviceId: string) {
|
||||
const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader";
|
||||
const selectedDevice = devices.find((device) => device.id === deviceId);
|
||||
const reader = selectedDevice?.deviceType === "usb_sim_reader";
|
||||
setForm((current) => ({
|
||||
...current, deviceId, profileIccid: "", profileAid: "",
|
||||
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
|
||||
environment: reader || !advancedTasksAvailable ? "vowifi" : current.environment,
|
||||
}));
|
||||
void loadProfiles(deviceId);
|
||||
void loadProfiles(deviceId, "", currentDeviceICCID(selectedDevice));
|
||||
}
|
||||
|
||||
function chooseProfile(iccid: string) {
|
||||
@@ -310,7 +339,7 @@ export default function AutomaticTasksPage() {
|
||||
async function save() {
|
||||
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
|
||||
if (!form.deviceId) return message.warning(t("请选择设备"));
|
||||
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
|
||||
if (!form.profileIccid) return message.warning(t("请选择 SIM 卡或 eSIM Profile"));
|
||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
|
||||
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
|
||||
}
|
||||
@@ -344,7 +373,7 @@ export default function AutomaticTasksPage() {
|
||||
body,
|
||||
});
|
||||
message.success(t(form.id ? "自动任务已更新" : "自动任务已创建"));
|
||||
setOpen(false);
|
||||
closeEditor();
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
@@ -411,8 +440,8 @@ export default function AutomaticTasksPage() {
|
||||
<PageHeader
|
||||
title={t("自动任务")}
|
||||
subtitle={advancedTasksAvailable
|
||||
? t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
|
||||
: t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
|
||||
? t("按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
|
||||
: t("按周期使用指定 SIM 卡或切换到指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
|
||||
/>
|
||||
|
||||
@@ -422,7 +451,7 @@ export default function AutomaticTasksPage() {
|
||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("任务")}</th>
|
||||
<th className="px-4 py-3">{t("设备 / Profile")}</th>
|
||||
<th className="px-4 py-3">{t("设备 / SIM / Profile")}</th>
|
||||
<th className="px-4 py-3">{t("类型")}</th>
|
||||
<th className="px-4 py-3">{t("执行环境")}</th>
|
||||
<th className="px-4 py-3">{t("周期")}</th>
|
||||
@@ -466,7 +495,7 @@ export default function AutomaticTasksPage() {
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<SendClockRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无自动任务")}</div>
|
||||
<div className="mt-1 text-xs">{t("添加任务后,系统会按设备排队并在执行前校验目标 Profile")}</div>
|
||||
<div className="mt-1 text-xs">{t("添加任务后,系统会按设备排队并在执行前校验目标 SIM / Profile")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
@@ -498,11 +527,11 @@ export default function AutomaticTasksPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
|
||||
<Modal open={open} onClose={closeEditor} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
|
||||
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("SIM / Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 SIM / Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
|
||||
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={taskTypeOptions} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
|
||||
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
|
||||
@@ -519,7 +548,7 @@ export default function AutomaticTasksPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("启用任务")}</div><div className="text-xs text-gray-400">{t("停用后不会进入执行队列")}</div></div><Switch checked={form.enabled} onChange={(enabled) => setForm({ ...form, enabled })} /></div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("完成后推送通知")}</div><div className="text-xs text-gray-400">{t("发送到全部已配置并启用的通知渠道")}</div></div><Switch checked={form.notify} onChange={(notify) => setForm({ ...form, notify })} /></div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2"><Button onClick={() => setOpen(false)}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></div>
|
||||
<div className="mt-5 flex justify-end gap-2"><Button onClick={closeEditor}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -136,11 +136,17 @@ export default function DevicesPage() {
|
||||
|
||||
const loadDiscovered = useCallback(async () => {
|
||||
setDiscovering(true);
|
||||
// Never leave a previous physical scan visible while a new scan is in
|
||||
// progress or after it fails.
|
||||
setDiscovered([]);
|
||||
setAddSelected(null);
|
||||
setAddConfig(EMPTY_ADD);
|
||||
try {
|
||||
const res = await api<{ devices?: DiscoveredDevice[] }>("/devices/discovered?with_imei=1");
|
||||
setDiscovered(Array.isArray(res?.devices) ? res!.devices! : []);
|
||||
const devices = Array.isArray(res?.devices) ? res!.devices! : [];
|
||||
setDiscovered(devices);
|
||||
} catch {
|
||||
/* ignore */
|
||||
setDiscovered([]);
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
@@ -296,13 +302,13 @@ export default function DevicesPage() {
|
||||
try {
|
||||
await api("/devices/actions/rescan", { method: "POST" });
|
||||
message.success(t("设备重新扫描完成"));
|
||||
await loadDevices(true);
|
||||
await Promise.all([loadDevices(true), loadDiscovered()]);
|
||||
} catch (e) {
|
||||
message.error(apiMessage(e) || t("重新扫描失败"));
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
}, [loadDevices]);
|
||||
}, [loadDevices, loadDiscovered]);
|
||||
|
||||
const handleOpenSms = useCallback(() => {
|
||||
const id = selectedIdRef.current;
|
||||
@@ -587,6 +593,7 @@ export default function DevicesPage() {
|
||||
|
||||
const detailOnline = isDeviceOnline(detail);
|
||||
const isReader = detail?.deviceType === "usb_sim_reader";
|
||||
const isNative410 = detail?.deviceType === "wifi_410";
|
||||
useEffect(() => {
|
||||
if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview");
|
||||
}, [isReader, activeTab]);
|
||||
@@ -690,6 +697,7 @@ export default function DevicesPage() {
|
||||
onRebootModem={handleRebootModem}
|
||||
onOpenSms={handleOpenSms}
|
||||
wifiCallingOnly={isReader}
|
||||
modemControlOnly={isNative410}
|
||||
/>
|
||||
<div className="device-detail-tabs ui-card p-6">
|
||||
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />
|
||||
@@ -736,6 +744,7 @@ export default function DevicesPage() {
|
||||
addConfig={addConfig}
|
||||
addSaving={addSaving}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onRefresh={() => void loadDiscovered()}
|
||||
onSelectDevice={selectDiscovered}
|
||||
onConfigChange={setAddConfig}
|
||||
onSave={saveAdd}
|
||||
|
||||
+98
-11
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AddRegular } from "@fluentui/react-icons";
|
||||
import { AddRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import { api, ApiError, apiMessage } from "../api";
|
||||
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
|
||||
import type { Country, CountryRule, DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
|
||||
import { usePolling } from "../lib/usePolling";
|
||||
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../components/proxy/shared";
|
||||
import { UpstreamDialog } from "../components/proxy/UpstreamDialog";
|
||||
import { DeviceBindingsDialog } from "../components/proxy/DeviceBindingsDialog";
|
||||
import { CountryRulesDialog } from "../components/proxy/CountryRulesDialog";
|
||||
import { UpstreamSection } from "../components/proxy/UpstreamSection";
|
||||
import { tf, useI18n } from "../lib/i18n";
|
||||
import { listPlugins, pluginAssetURL, type InstalledPlugin } from "../extensions";
|
||||
@@ -24,11 +25,13 @@ interface BindingMutationResult {
|
||||
}
|
||||
|
||||
export default function ProxyPage() {
|
||||
const { t } = useI18n();
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const [proxies, setProxies] = useState<UpstreamProxy[]>([]);
|
||||
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
||||
const [bindings, setBindings] = useState<DeviceProxyBinding[]>([]);
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [countryRules, setCountryRules] = useState<CountryRule[]>([]);
|
||||
const [upstreamLoading, setUpstreamLoading] = useState(true);
|
||||
const [upstreamError, setUpstreamError] = useState<LoadError | null>(null);
|
||||
const [upstreamDialogOpen, setUpstreamDialogOpen] = useState(false);
|
||||
@@ -39,28 +42,46 @@ export default function ProxyPage() {
|
||||
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
|
||||
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
|
||||
const [bindingBusy, setBindingBusy] = useState(false);
|
||||
const [countryDialogOpen, setCountryDialogOpen] = useState(false);
|
||||
const [countryBusy, setCountryBusy] = useState(false);
|
||||
const [toggleBusyId, setToggleBusyId] = useState("");
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
||||
|
||||
const proxyRows = useMemo<UpstreamRow[]>(
|
||||
() => proxies.map((proxy) => ({
|
||||
const regionNames = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
const proxyRows = useMemo<UpstreamRow[]>(() => proxies.map((proxy) => {
|
||||
const countryNames = countryRules
|
||||
.filter((rule) => rule.enabled && rule.upstreamProxyId === proxy.id)
|
||||
.map((rule) => regionNames?.of(rule.countryCode) || rule.countryName || rule.countryCode);
|
||||
return {
|
||||
...proxy,
|
||||
bindingCount: bindings.filter((binding) => binding.upstreamProxyId === proxy.id).length,
|
||||
})),
|
||||
[proxies, bindings],
|
||||
);
|
||||
countryNames,
|
||||
};
|
||||
}), [proxies, bindings, countryRules, regionNames]);
|
||||
|
||||
const loadUpstream = useCallback(async (initial = false) => {
|
||||
if (initial) setUpstreamLoading(true);
|
||||
setUpstreamError(null);
|
||||
try {
|
||||
const [proxyList, bindingList, deviceList] = await Promise.all([
|
||||
const [proxyList, bindingList, deviceList, countryList, ruleList] = await Promise.all([
|
||||
api<UpstreamProxy[]>("/upstream-proxies"),
|
||||
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
|
||||
api<DevicesResponse>("/devices"),
|
||||
api<Country[]>("/upstream-proxy-countries"),
|
||||
api<CountryRule[]>("/upstream-proxy-country-rules"),
|
||||
]);
|
||||
setProxies(proxyList || []);
|
||||
setBindings(bindingList || []);
|
||||
setDevices(deviceList?.devices || []);
|
||||
setCountries(countryList || []);
|
||||
setCountryRules(ruleList || []);
|
||||
} catch (error) {
|
||||
setUpstreamError({ message: apiMessage(error), status: error instanceof ApiError ? error.status : undefined });
|
||||
} finally {
|
||||
@@ -165,6 +186,8 @@ export default function ProxyPage() {
|
||||
{tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
|
||||
<br />
|
||||
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
|
||||
<br />
|
||||
{t("绑定到该代理的国家规则将自动删除,相关国家会恢复直连。")}
|
||||
</>,
|
||||
t("确认删除"),
|
||||
{ confirmText: t("删除"), cancelText: t("取消"), type: "warning" },
|
||||
@@ -174,6 +197,7 @@ export default function ProxyPage() {
|
||||
await api(`/upstream-proxies/${proxy.id}`, { method: "DELETE" });
|
||||
message.success(t("上游代理已删除"));
|
||||
if (bindingsProxy?.id === proxy.id) setBindingsDialogOpen(false);
|
||||
setCountryDialogOpen(false);
|
||||
await loadUpstream(false);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("删除失败"));
|
||||
@@ -185,6 +209,53 @@ export default function ProxyPage() {
|
||||
setBindingsDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const toggleUpstream = useCallback(async (proxy: UpstreamProxy) => {
|
||||
const enabled = !proxy.enabled;
|
||||
setToggleBusyId(proxy.id);
|
||||
try {
|
||||
const result = await api<BindingMutationResult>(`/upstream-proxies/${encodeURIComponent(proxy.id)}`, {
|
||||
method: "PATCH",
|
||||
body: { enabled },
|
||||
});
|
||||
if (result.reconnectError) {
|
||||
message.warning(`${enabled ? t("代理已启用") : t("代理已禁用")};${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
|
||||
} else if (enabled) {
|
||||
message.success(t("代理已启用"));
|
||||
} else {
|
||||
message.success(t("代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连"));
|
||||
}
|
||||
await loadUpstream(false);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error) || t("切换代理状态失败"));
|
||||
} finally {
|
||||
setToggleBusyId("");
|
||||
}
|
||||
}, [loadUpstream, t]);
|
||||
|
||||
const saveCountryRules = useCallback(async (assignments: Record<string, string>) => {
|
||||
setCountryBusy(true);
|
||||
const current = new Map(countryRules.map((rule) => [rule.countryCode, rule.upstreamProxyId]));
|
||||
const changed = Object.entries(assignments).filter(([code, proxyID]) => proxyID && current.get(code) !== proxyID);
|
||||
const removed = countryRules.filter((rule) => !assignments[rule.countryCode]);
|
||||
try {
|
||||
await Promise.all(changed.map(([code, proxyID]) => api(`/upstream-proxy-country-rules/${encodeURIComponent(code)}`, {
|
||||
method: "PUT",
|
||||
body: { upstreamProxyId: proxyID, enabled: true },
|
||||
})));
|
||||
await Promise.all(removed.map((rule) => api(`/upstream-proxy-country-rules/${encodeURIComponent(rule.countryCode)}`, {
|
||||
method: "DELETE",
|
||||
})));
|
||||
message.success(t("国家规则已保存"));
|
||||
await loadUpstream(false);
|
||||
setCountryDialogOpen(false);
|
||||
} catch (error) {
|
||||
await loadUpstream(false);
|
||||
message.error(apiMessage(error) || t("保存规则失败"));
|
||||
} finally {
|
||||
setCountryBusy(false);
|
||||
}
|
||||
}, [countryRules, loadUpstream, t]);
|
||||
|
||||
const showRouteChangeResult = useCallback((result: BindingMutationResult, successText: string) => {
|
||||
if (result.reconnectError) {
|
||||
message.warning(`${successText};${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
|
||||
@@ -241,8 +312,13 @@ export default function ProxyPage() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title={t("代理管理")}
|
||||
subtitle={t("管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
|
||||
subtitle={t("管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定")}
|
||||
actions={(
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<GlobeRegular />} onClick={() => setCountryDialogOpen(true)}>{t("MCC 国家规则")}</Button>
|
||||
<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<UpstreamSection
|
||||
rows={proxyRows}
|
||||
@@ -252,6 +328,8 @@ export default function ProxyPage() {
|
||||
onEdit={openUpstreamDialog}
|
||||
onDelete={removeUpstream}
|
||||
onOpenBindings={openBindingsDialog}
|
||||
onToggle={(proxy) => void toggleUpstream(proxy)}
|
||||
toggleBusyId={toggleBusyId}
|
||||
/>
|
||||
{plugins.filter((plugin) => plugin.enabled).flatMap((plugin) =>
|
||||
plugin.contributions.filter((contribution) => contribution.location === "proxy").map((contribution) => (
|
||||
@@ -293,6 +371,15 @@ export default function ProxyPage() {
|
||||
onDelete={(iccids) => void deleteProfileBindings(iccids)}
|
||||
onClose={() => setBindingsDialogOpen(false)}
|
||||
/>
|
||||
<CountryRulesDialog
|
||||
open={countryDialogOpen}
|
||||
proxies={proxies}
|
||||
countries={countries}
|
||||
rules={countryRules}
|
||||
busy={countryBusy}
|
||||
onSave={(assignments) => void saveCountryRules(assignments)}
|
||||
onClose={() => setCountryDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface VoWiFiRuntime {
|
||||
phase: string;
|
||||
enabled?: boolean;
|
||||
active?: boolean;
|
||||
carrierProfile?: string;
|
||||
carrierProfileFrom?: string;
|
||||
dataplaneMode: string;
|
||||
iccid: string;
|
||||
imsi: string;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const source = await readFile(new URL("../src/lib/automaticTaskProfiles.ts", import.meta.url), "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ES2022,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
});
|
||||
const moduleURL = `data:text/javascript;base64,${Buffer.from(compiled.outputText).toString("base64")}`;
|
||||
const {
|
||||
buildAutomaticTaskProfileOptions,
|
||||
createAutomaticTaskProfileRequestGuard,
|
||||
selectAutomaticTaskProfileOption,
|
||||
} = await import(moduleURL);
|
||||
|
||||
test("uses the current physical SIM when the device has no eSIM profiles", () => {
|
||||
const iccid = "89441000400128014257";
|
||||
|
||||
assert.deepEqual(buildAutomaticTaskProfileOptions([], iccid, "Current SIM"), [
|
||||
{
|
||||
iccid,
|
||||
aidHex: "",
|
||||
label: `Current SIM · ${iccid}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
|
||||
const iccid = "89441000400128014257";
|
||||
|
||||
assert.deepEqual(
|
||||
buildAutomaticTaskProfileOptions(
|
||||
[{ aidHex: "a0000005591010ffffffff8900000100", profiles: [{ iccid, name: "Travel" }] }],
|
||||
iccid,
|
||||
"Current SIM",
|
||||
),
|
||||
[
|
||||
{
|
||||
iccid,
|
||||
aidHex: "a0000005591010ffffffff8900000100",
|
||||
label: `Travel · ${iccid}`,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not replace a saved profile when a failed inventory only exposes the current SIM", () => {
|
||||
const currentICCID = "89441000400128014257";
|
||||
const savedICCID = "89104100000028106378";
|
||||
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
||||
|
||||
assert.equal(selectAutomaticTaskProfileOption(options, savedICCID), undefined);
|
||||
});
|
||||
|
||||
test("accepts state updates only from the latest profile request", () => {
|
||||
const guard = createAutomaticTaskProfileRequestGuard();
|
||||
const first = guard.begin();
|
||||
const second = guard.begin();
|
||||
|
||||
assert.equal(guard.isCurrent(first), false);
|
||||
assert.equal(guard.isCurrent(second), true);
|
||||
guard.invalidate();
|
||||
assert.equal(guard.isCurrent(second), false);
|
||||
});
|
||||
Reference in New Issue
Block a user