Compare commits

..
97 Commits
Author SHA1 Message Date
Rain SevenandGitHub 6ec950bfd2 style: gofmt IMS security policies (#44) 2026-08-17 00:14:42 +08:00
04dd026e95 test: align IMS registration expectations (#43)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 23:46:28 +08:00
a2a72dfd30 test: cover SMS state across restarts (#42)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 23:36:21 +08:00
67079c99b9 feat: add DJI QMI recovery doctor (#41)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 23:35:38 +08:00
f949001480 fix: support non-Quectel Qualcomm modems and fix 410 dongle AT timeouts (#40)
1. Vendor-neutral modem compatibility:
   - Discovery switched from a vendor-ID whitelist to detecting the QMI
     channel directly (an interface bound to the kernel qmi_wwan driver),
     so SIMCom, Sierra, Telit and other Qualcomm-based modules are found
     automatically while MBIM-only devices stay excluded
   - AT port responses now distinguish an AT command error from firmware
     incompatibility: ERROR / +CME ERROR is returned as a normal response
     (200) instead of being folded into a 502, which only a real transport
     failure produces

2. Fixed the 410 dongle's AT command timeouts:
   - Default WWAN AT port switched from wwan0at0 to wwan0at1: ModemManager
     marks the first AT port that answers its probe as primary (at1 on the
     tested UFI dongles) and closes AT ports once initialization finishes,
     so at1 is the responsive, idle channel for vocat while MM uses the
     QMI port for control
   - Drain the WWAN input buffer before each command write, discarding the
     late bytes of a previous timed-out command so they cannot pollute the
     next response's parsing
   - AT+CGSN now uses an independent short timeout instead of inheriting
     the refresh's 30s deadline (on MHI modems it returns the IMEI line
     but never a final OK). Previously every refresh held the device lock
     for the full 30s, queueing AT terminal commands behind it for 10-20s
   - The QMI UIM ICCID fallback only runs when AT+CPIN? already proved a
     READY card, so a SIM-less slot no longer blocks refresh waiting out
     its long timeout

Tests: added WWAN drain cleanup, drain-before-write ordering, CGSN timeout
bound, skip-QMI-ICCID-without-SIM, CommandError-as-200, WWAN at1 port
selection and vendor-neutral discovery cases. go vet and go test ./... pass.

Co-authored-by: Test <[email protected]>
2026-08-16 23:34:12 +08:00
fa8afb9571 fix: deliver Vodafone UK MT SMS over WiFi Calling (#39)
- Read ipsec-3gpp UDP server port even when REGISTER used TCP
- Advertise Allow MESSAGE and smsip Accept-Contact for all carriers
- Do not require P-CSCF port-c (50601) on inbound UE-server XFRM
- Log rejected inbound TCP/UDP sources

Verified on VOXI/Vodafone UK 23415 with Quectel EC25.

Closes #38

Co-authored-by: wl77vv <[email protected]>
2026-08-16 22:24:33 +08:00
bfda29193a feat: add operational health and metrics endpoints (#37)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 21:28:01 +08:00
ed64fd428a fix: restart the active systemd unit after update (#36)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 21:25:50 +08:00
Rain SevenandGitHub f84a1f99b1 feat: verify SOCKS5 with real UDP round trip (#35) 2026-08-16 21:24:14 +08:00
MengMengCodeandClaude Opus 4.8 e2177a6e9a feat: remove legacy device stat panels from dashboard
The total/online/offline/last-refresh mini panels are superseded by the
new module online-rate card.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 18:33:25 +08:00
MengMengCodeandClaude Opus 4.8 115598757a feat: dashboard host, performance, task, and online-rate cards
Add four cards to the dashboard:

- Host hardware card (CPU / motherboard / memory / disk model) backed by
  a new GET /api/dashboard/host endpoint that probes /proc and /sys once
  and caches the identities. x86 hosts read cpuinfo model name, DMI board
  data, dmidecode DIMM info, and block device models; ARM boards compose
  the device-tree SoC with the Cortex part name and fall back to memory
  capacity.
- Performance card with live CPU / memory / disk usage bars and real-time
  network up/down rates. Rates derive from cumulative kernel counters
  sampled on demand by dashboard polling (no background goroutine), with
  bridge/tunnel/vocat virtual interfaces excluded to avoid double
  counting.
- Upcoming scheduled tasks card listing the next three enabled automatic
  tasks with their run times.
- Module online rate card aggregating all recognized modules into one
  large percentage colored by four levels (red/orange/yellow/green).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 17:46:38 +08:00
Rain SevenandGitHub 297d2c1839 fix: deduplicate cumulative IMS SMS segments (#34) 2026-08-16 17:09:08 +08:00
Rain SevenandGitHub 7ba30132f9 fix: retry interrupted serial drain (#33) 2026-08-16 17:09:01 +08:00
Rain SevenandGitHub 82494f519b feat: auto-detect DJI 4G modules (#32) 2026-08-16 17:08:51 +08:00
MengMengCode 5eee89a92a feat: Enhance SIM identity handling and carrier profile integration
- Added support for reading SIM metadata (SPN, GID1, GID2) in EC20 and Native QMI adapters.
- Refactored ePDG resolver to utilize carrier profiles for DNS resolution.
- Introduced automatic legacy proposal fallback in IKE provider based on negotiation failures.
- Updated IMS provider to cache transport settings per SIM identity and implement transport fallback logic.
- Enhanced SMS center retrieval to fall back to carrier profiles when no explicit configuration is found.
- Updated state management to include carrier profile information.
- Improved integration tests to cover new transport caching and SMS center logic.
- Added UI components to display carrier profile and match source in the device overview.
- Updated internationalization files to include new labels for carrier profile and match source.
2026-08-16 16:10:53 +08:00
MengMengCode ae3a2a6eea FIX #29 2026-08-16 15:16:45 +08:00
MengMengCode 505ee1eac0 FIX #27 2026-08-16 13:49:42 +08:00
MengMengCode b19ae2240a FIX #25 2026-08-16 13:33:55 +08:00
MengMengCode adf7de6d29 FIX #28 2026-08-16 13:20:50 +08:00
MengMengCode ffa0fd23b8 feat: enhance IMS SMS handling with structured logging and payload extraction 2026-08-16 13:05:20 +08:00
MengMengCode f014628048 feat: add curl options for binary download with progress feedback 2026-08-16 12:02:13 +08:00
MengMengCode 68813198f9 fix: complete native 410 eSIM and VoWiFi flow 2026-08-16 02:54:38 +08:00
MengMengCode 5774b196d2 feat: add issue report template for bug reporting and triage 2026-08-16 02:45:23 +08:00
AlexandGitHub 5604813254 fix: support physical SIM automatic tasks (#26) 2026-08-16 01:48:23 +08:00
MengMengCode 38b4264133 Merge branch 'master' of https://github.com/MengMengCode/VoCat 2026-08-15 23:48:35 +08:00
MengMengCode df9dbfee74 feat: implement fresh device discovery and update UI for device refresh 2026-08-15 23:48:32 +08:00
8dc6d4f620 feat: add native OpenStick 410 modem support (#23)
* build: add QMI dependency and license attribution

* feat: add native OpenStick 410 modem support

---------

Co-authored-by: w0x41en <[email protected]>
2026-08-15 23:45:15 +08:00
Meng MengandGitHub 70bdb88e4c Refactor PR size limit workflow for clarity and efficiency 2026-08-15 22:29:45 +08:00
Meng MengandGitHub 091d12cd30 Enhance PR size limit workflow with timeout and permissions
Increased timeout for the PR size limit check from 2 to 5 minutes. Added issue permissions and improved PR size calculation steps.
2026-08-15 22:24:00 +08:00
MengMengCode c4c6a6c181 fix: support Fake-IP notifications and resilient updates 2026-08-14 22:59:46 +08:00
Meng MengandGitHub 540a967f96 fix: remove CLI credential timeout and password complexity limits (#20) 2026-08-14 22:46:37 +08:00
Meng MengandGitHub fcf57ffec9 Feat/admin credentials pr limit (#19)
* feat: reset admin credentials without requiring current password and update related prompts

* feat: update password requirements to a minimum of 6 characters for admin credentials
2026-08-14 21:52:53 +08:00
MengMengCode 4b1bcdf582 feat: add XeSIM CTE and RedPocket VoWiFi compatibility 2026-08-14 21:15:34 +08:00
Meng MengandGitHub 0d1779bf25 fix: prevent schema-incompatible installer downgrades (#18)
Add schema 17–19 compatibility migrations
2026-08-14 20:59:45 +08:00
Meng MengandGitHub 08cdd99141 feat: reset admin credentials without requiring current password and update related prompts (#17) 2026-08-14 20:38:15 +08:00
Meng MengandGitHub f6cd31f50b Merge pull request #16 from CwithW/fix-dockerfile-missing-iproute2
fix: install iproute2 in runtime image
2026-08-14 20:00:40 +08:00
Chara White e442dde284 fix: install iproute2 in runtime image 2026-08-14 19:50:01 +08:00
MengMengCode 083a952480 feat: add notification destination context for Telegram polling and implement related test 2026-08-14 19:09:31 +08:00
MengMengCode 67f2ddbaeb feat: add home carrier information to ModemSummary interface 2026-08-14 00:22:39 +08:00
MengMengCode e546e810fe Rollback 2026-08-13 23:38:45 +08:00
MengMengCode 30c0d2d9d5 Implement 3GPP Device Identity Handling in IKE
- Added support for DEVICE_IDENTITY request as per TS 24.302.
- Implemented deviceIdentityRequested function to check for DEVICE_IDENTITY notifications.
- Created deviceIdentityNotify function to construct DEVICE_IDENTITY notifications with IMEI/IMEISV.
- Enhanced security proposal to accommodate O2 Germany's integrity-only ESP profile.
- Updated tests to validate DEVICE_IDENTITY encoding and request handling.
- Refactored security proposal to include fallback encryption and integrity algorithms.
- Improved session management to ensure proper cleanup of IPSec resources after caller deadline.
- Added comprehensive tests for security agreement parsing and algorithm support.

FIX #11
2026-08-13 23:30:00 +08:00
MengMengCode 64cd5714ef fix: support O2 Germany certificate-authenticated EAP 2026-08-13 21:28:38 +08:00
MengMengCode aa54ad8f6a fix: stabilize EM7430 hotplug support 2026-08-13 20:58:24 +08:00
MengMengCode c7c7174b45 feat: support Sierra EM7430 MBIM modems 2026-08-13 20:05:07 +08:00
MengMengCode b45f7825ca fix: bind physical SIMs and validate multi-arch images 2026-08-13 11:01:36 +08:00
MengMengCode c436e12532 fix: discover USB smart card readers without pcscd 2026-08-13 10:53:56 +08:00
MengMengCode 0c380c1e07 fix: persist admin credentials in database and discover MHI modems 2026-08-13 10:46:12 +08:00
Meng MengandGitHub c63c765d14 Merge pull request #7 from jiuliking/fix/plmn-23410-ims-interoperability
fix: support PLMN 234-10 IMS registration
2026-08-13 10:17:10 +08:00
Alex 18408106fa [verified] fix: support PLMN 234-10 IMS registration
Select UDP P-CSCF signalling for the explicit 234-10 and 234-010 PLMN forms while preserving exact MNC length semantics. Skip incomplete ipsec-3gpp algorithm advertisements so a later complete Security-Server offer can be selected, while keeping malformed and partially specified SA parameters fail-closed.
2026-08-13 01:45:16 +08:00
MengMengCode d0fd59a2a4 Enforce hard limits and hide unavailable task paths 2026-08-12 17:24:39 +08:00
MengMengCode 79ab0573e0 Update Readme.md 2026-08-12 17:09:06 +08:00
MengMengCode 053d9d275c Update Readme.md 2026-08-12 16:57:16 +08:00
MengMengCode 07a20201e7 Make EC20 eSIM channels coexist with VoWiFi 2026-08-12 16:56:30 +08:00
MengMengCode 6dbceaa25c Wait for OpenWrt service shutdown during upgrades 2026-08-12 16:36:04 +08:00
MengMengCode ccf4de3f9a Stabilize OpenWrt modem startup and upgrades 2026-08-12 16:30:22 +08:00
MengMengCode d63325f06d Fix static ARM builds and OpenWrt VoWiFi setup 2026-08-12 15:58:02 +08:00
Meng MengandGitHub c73d95b2b6 Merge pull request #3 from MengMengCode/dependabot/go_modules/go_modules-a3c8a40308
build(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in the go_modules group across 1 directory
2026-08-12 02:38:34 +08:00
dependabot[bot]andGitHub 25e1c8fdd9 build(deps): bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto).


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

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-11 18:35:30 +00:00
MengMengCode 8ff337001e Add PC/SC backend support for USB SIM readers
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms.
- Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading.
- Added methods for reading identity, checking readiness, and authenticating with USIM applications.
- Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses.
- Defined necessary types and error handling in `types.go` for better clarity and usability.
- Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities.
- Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`.
- Implemented tests to ensure correct functionality and error handling in various scenarios.
2026-08-12 02:33:32 +08:00
Meng MengandGitHub c7e05b201b Add star history badge to README 2026-08-11 23:52:46 +08:00
MengMengCode 09cdd8c102 fix: update expected local time format in WeCom SMS values test 2026-08-11 23:06:48 +08:00
Meng MengandGitHub 154eb24700 Merge pull request #2 from zAhYAng/feat/wecom-notifications
feat: add WeCom message push notifications
2026-08-11 22:56:19 +08:00
zAhYAng 9a0f8f4b79 fix: polish WeCom notification settings 2026-08-11 22:28:36 +08:00
MengMengCode e4434bbfbf feat: add custom phone number support to card policies
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema.
- Updated the API to handle custom phone number input, including validation and normalization.
- Modified the CardPolicyPanel component to allow users to set and save a custom phone number.
- Enhanced the settings API to include the custom phone number in responses and updates.
- Added tests to ensure the correct functionality of custom phone number handling.
- Removed hardcoded environment variable for VOCAT_ADDR in service files.
2026-08-11 21:58:28 +08:00
zAhYAng f530e7722d feat: add WeCom notification settings 2026-08-11 21:41:43 +08:00
zAhYAng 4f7873f3b1 feat: dispatch WeCom notifications 2026-08-11 21:39:53 +08:00
zAhYAng 8bb20aa9f0 feat: configure WeCom notifications 2026-08-11 21:38:09 +08:00
zAhYAng 342d88e0cf feat: add WeCom payload renderer 2026-08-11 21:33:38 +08:00
zAhYAng 328605336b docs: plan WeCom notifications and worktree setup 2026-08-11 21:19:02 +08:00
MengMengCode 30afe090d3 feat: enhance email validation to prevent injection attacks and improve related tests 2026-08-11 19:41:06 +08:00
MengMengCode ede7a8aa19 feat: implement automatic task recovery logic and enhance related tests 2026-08-11 19:32:22 +08:00
MengMengCode 177dde48b0 feat: enhance security by preventing exposure of sensitive credentials in logs and errors 2026-08-11 19:07:58 +08:00
MengMengCode 07e47d012d feat: implement eSIM notification handling and email message construction
- Add eSIM notification management in `esim_notifications.go` with functions to retrieve, list, and remove notifications.
- Implement parsing logic for pending notifications and notification metadata.
- Create tests for eSIM notification parsing and request handling in `esim_notifications_test.go`.
- Introduce email message construction in `email_message.go` to securely format and send emails.
- Add tests for email message encoding and validation in `email_message_test.go`.
- Enhance the CardPolicyAPN component to manage APN configurations, including adding, editing, and deleting custom APNs.
- Implement UI for displaying and managing APN settings with appropriate validation and user feedback.
2026-08-11 18:58:32 +08:00
MengMengCode 111b3de6f2 support offline flag 2026-08-11 15:59:47 +08:00
MengMengCode ecf36a8f2e feat: enhance URL validation and email address parsing; refactor related components 2026-08-11 02:02:55 +08:00
MengMengCode be174d09ae feat: update schema version and refactor proxy binding logic
- Increment schema version from 11 to 12.
- Modify ProxyResolver to use ICCID for device proxy binding resolution.
- Update tests to reflect changes in proxy binding logic using ICCID.
- Enhance DeviceBindingsDialog to manage eSIM profile bindings instead of device bindings.
- Update UI components and translations to reflect the new profile binding terminology.
- Implement pagination for automatic task runs in AutomaticTasksPage.
- Create a new Pagination component for better navigation in lists.
2026-08-11 01:23:04 +08:00
MengMengCode e9d8dbf996 update 2026-08-11 00:22:54 +08:00
MengMengCode 704e5d0656 update readme 2026-08-10 23:05:41 +08:00
MengMengCode f4aaa3cfdd Implement automatic task management with CRUD operations and UI integration
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks.
- Created `automatic_tasks_test.go` to validate task claiming and deletion behavior.
- Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution.
- Integrated device and eSIM profile selection for task configuration.
- Implemented automatic task scheduling and retry logic in the backend.
2026-08-10 22:15:54 +08:00
MengMengCode 70191ce1e3 feat: enhance Telegram call handling with VoWiFi and cellular support 2026-08-10 06:07:56 +08:00
MengMengCode 7dfdf8fcc8 feat: expand device networking and management 2026-08-10 03:27:43 +08:00
MengMengCode f1b7fc3c56 fix: expose IMS call failures and allow unlimited calls 2026-08-09 21:52:25 +08:00
MengMengCode 6d451ca9a3 fix: route calls only through ready IMS sessions 2026-08-09 21:31:24 +08:00
MengMengCode 42a21c6dc2 fix: redact Telegram tokens from errors 2026-08-09 21:14:07 +08:00
MengMengCode 6882b04fda feat: add Telegram AT and USSD commands 2026-08-09 21:06:35 +08:00
MengMengCode a8e8caf78d fix: redirect expired sessions to login 2026-08-09 20:47:53 +08:00
MengMengCode 670b029b38 fix: revoke sessions after updates 2026-08-09 20:23:00 +08:00
MengMengCode a5c6fee2ef fix: make web updates restart cleanly 2026-08-09 20:13:50 +08:00
MengMengCode 79dc2bc934 fix: allow verified in-place updates 2026-08-09 20:09:44 +08:00
MengMengCode 7f162d43ef build: speed up multi-arch Docker builds 2026-08-09 19:59:05 +08:00
MengMengCode 1016162600 feat: update SMS handling to include readyForClose channel for better session management 2026-08-09 19:35:00 +08:00
MengMengCode f3e4b675fb feat: add Telegram API URL handling and version comparison utilities
- Implemented `telegramAPIURL` and `validateTelegramAPIURL` functions for constructing and validating Telegram API URLs.
- Added semantic versioning utilities in `version.go` to compare versions and validate semantic version formats.
- Created tests for version comparison logic in `version_test.go`.
- Introduced IMS call handling in `call_runtime.go`, including methods for dialing, answering, and hanging up calls.
- Added tests for incoming call handling and validation in `call_runtime_test.go`.
- Developed a new `PluginsCard` component for managing plugins via URL or file upload in the web interface.
- Implemented plugin management functions in `extensions.ts` and created an `ExtensionPage` for displaying plugin contributions.
2026-08-09 19:28:12 +08:00
MengMengCode 8fdce234ce Merge branch 'master' of https://github.com/MengMengCode/VoCat 2026-08-09 16:59:32 +08:00
Meng MengandGitHub 0ed066fcc7 Update README.md 2026-08-09 16:38:56 +08:00
Meng MengandGitHub cbeb6cdd21 Add group and channel information
Added group and channel links to README.
2026-08-09 16:38:05 +08:00
Meng MengandGitHub cd04642e50 Update thanks section in README.md 2026-08-09 16:25:14 +08:00
Meng MengandGitHub ddec50a7b6 Add 'Thanks' section to README
Added a 'Thanks' section with community links.
2026-08-09 16:21:18 +08:00
597 changed files with 64893 additions and 2261 deletions
+4
View File
@@ -0,0 +1,4 @@
# VoCat no longer stores administrator credentials in .env.
# Initialize a new Docker database with the bootstrap-admin command documented
# at the top of docker-compose.yml. Keep this file only for optional, non-secret
# Compose substitutions added by an operator.
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+75
View File
@@ -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
+43
View File
@@ -15,7 +15,50 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
smoke:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
- platform: linux/arm64
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ${{ matrix.platform }} smoke image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
load: true
push: false
tags: vocat-smoke:${{ matrix.arch }}
build-args: |
VERSION=0.0.0-smoke
BUILD_TIME=${{ github.event.repository.updated_at }}
cache-from: type=gha
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
run: |
docker run --rm --platform '${{ matrix.platform }}' \
vocat-smoke:${{ matrix.arch }} version
docker run --rm --platform '${{ matrix.platform }}' \
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \
'command -v pcscd && test -d /usr/lib/pcsc/drivers'
build-and-push: build-and-push:
needs: smoke
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
+282
View File
@@ -0,0 +1,282 @@
name: Pull request size limit
on:
pull_request_target:
branches:
- master
types:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: pr-size-limit-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
enforce-size-limit:
# 保持这个名字不变,这样你 Ruleset 里的 Required Check 不需要修改
name: Enforce 5,000-line limit
runs-on: ubuntu-latest
timeout-minutes: 5
env:
MAX_CHANGED_LINES: "5000"
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout trusted base repository
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Check conflicts and pull request size
shell: bash
run: |
set -euo pipefail
echo "Checking PR #${PR_NUMBER}"
echo "Base branch: ${BASE_REF}"
############################################################
# Helper: comment on and close rejected PR
############################################################
reject_pr() {
local message="$1"
echo "::error::${message}"
COMMENT_PAYLOAD="$(
jq -nc \
--arg body "${message}" \
'{body: $body}'
)"
echo "Posting rejection comment..."
curl \
--fail-with-body \
--silent \
--show-error \
--request POST \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--data "${COMMENT_PAYLOAD}" \
>/dev/null
echo "Closing PR #${PR_NUMBER}..."
curl \
--fail-with-body \
--silent \
--show-error \
--request PATCH \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
--data '{"state":"closed"}' \
>/dev/null
exit 1
}
############################################################
# Fetch target branch and PR HEAD
############################################################
echo "Fetching base branch and PR head..."
git fetch --no-tags --force origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/base-pr-check" \
"+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}"
BASE_COMMIT="$(
git rev-parse refs/remotes/origin/base-pr-check
)"
PR_COMMIT="$(
git rev-parse refs/remotes/origin/pr-${PR_NUMBER}
)"
echo "Base commit: ${BASE_COMMIT}"
echo "PR commit: ${PR_COMMIT}"
############################################################
# STEP 1: Reject PRs with merge conflicts
############################################################
echo
echo "Checking for merge conflicts..."
set +e
git merge-tree \
--write-tree \
--quiet \
"${BASE_COMMIT}" \
"${PR_COMMIT}"
MERGE_STATUS=$?
set -e
if [[ "${MERGE_STATUS}" -eq 1 ]]; then
{
echo "### Pull request policy"
echo
echo "- Merge conflicts: ❌ Detected"
echo "- Result: Rejected"
} >> "${GITHUB_STEP_SUMMARY}"
reject_pr "This pull request has merge conflicts with the current master branch and cannot be accepted. Please update your branch with the latest master, resolve all merge conflicts locally, and submit a conflict-free pull request."
elif [[ "${MERGE_STATUS}" -ne 0 ]]; then
echo "::error::Unable to determine whether the pull request can be merged."
echo "git merge-tree returned status ${MERGE_STATUS}."
{
echo "### Pull request policy"
echo
echo "- Merge conflict check: ⚠️ Error"
echo "- Result: Check failed"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "No merge conflicts detected."
############################################################
# STEP 2: Determine merge base
############################################################
if ! MERGE_BASE="$(
git merge-base "${BASE_COMMIT}" "${PR_COMMIT}"
)"; then
echo "::error::Unable to determine merge base."
{
echo "### Pull request policy"
echo
echo "- Merge conflicts: ✅ None"
echo "- Diff calculation: ⚠️ Failed"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "Merge base: ${MERGE_BASE}"
############################################################
# STEP 3: Calculate actual PR changed lines
############################################################
NUMSTAT_FILE="$(mktemp)"
git diff \
--no-ext-diff \
--no-textconv \
--numstat \
"${MERGE_BASE}" \
"${PR_COMMIT}" \
> "${NUMSTAT_FILE}"
ADDITIONS="$(
awk '
$1 ~ /^[0-9]+$/ {
total += $1
}
END {
print total + 0
}
' "${NUMSTAT_FILE}"
)"
DELETIONS="$(
awk '
$2 ~ /^[0-9]+$/ {
total += $2
}
END {
print total + 0
}
' "${NUMSTAT_FILE}"
)"
CHANGED_FILES="$(
awk '
END {
print NR + 0
}
' "${NUMSTAT_FILE}"
)"
CHANGED_LINES=$((ADDITIONS + DELETIONS))
############################################################
# Action summary
############################################################
{
echo "### Pull request policy"
echo
echo "- Merge conflicts: ✅ None"
echo "- Changed files: ${CHANGED_FILES}"
echo "- Additions: ${ADDITIONS}"
echo "- Deletions: ${DELETIONS}"
echo "- Total changed lines: ${CHANGED_LINES}"
echo "- Maximum allowed: ${MAX_CHANGED_LINES}"
} >> "${GITHUB_STEP_SUMMARY}"
echo
echo "Changed files: ${CHANGED_FILES}"
echo "Additions: ${ADDITIONS}"
echo "Deletions: ${DELETIONS}"
echo "Total changed lines: ${CHANGED_LINES}"
echo "Limit: ${MAX_CHANGED_LINES}"
############################################################
# STEP 4: Reject oversized PRs
############################################################
if (( CHANGED_LINES > MAX_CHANGED_LINES )); then
reject_pr "This pull request changes ${CHANGED_LINES} lines (${ADDITIONS} additions + ${DELETIONS} deletions) across ${CHANGED_FILES} files, exceeding the repository limit of ${MAX_CHANGED_LINES} changed lines. It has been closed automatically. Please split the changes into smaller pull requests."
fi
############################################################
# PASS
############################################################
echo
echo "Pull request passed all policy checks."
echo "No merge conflicts."
echo "Changed lines: ${CHANGED_LINES}/${MAX_CHANGED_LINES}."
{
echo
echo "### Result"
echo
echo "✅ Pull request passed."
} >> "${GITHUB_STEP_SUMMARY}"
+11
View File
@@ -93,6 +93,9 @@ jobs:
with: with:
name: web-dist name: web-dist
path: web/dist path: web/dist
- name: Set up QEMU for ARM64 runtime smoke test
if: matrix.goarch == 'arm64'
uses: docker/setup-qemu-action@v3
- name: Build ${{ matrix.target }} - name: Build ${{ matrix.target }}
env: env:
GOOS: linux GOOS: linux
@@ -110,6 +113,14 @@ jobs:
-o "$OUTPUT" \ -o "$OUTPUT" \
./cmd/vocat ./cmd/vocat
chmod 0755 "$OUTPUT" chmod 0755 "$OUTPUT"
if readelf -l "$OUTPUT" | grep -q 'Requesting program interpreter'; then
echo "ERROR: $OUTPUT unexpectedly requires a dynamic loader" >&2
readelf -l "$OUTPUT" >&2
exit 1
fi
case "${{ matrix.goarch }}" in
amd64|arm64) "$OUTPUT" version ;;
esac
- name: Upload ${{ matrix.target }} - name: Upload ${{ matrix.target }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
+5
View File
@@ -10,11 +10,15 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
/fix
# ---- Cookie / secret files (NEVER commit) ---- # ---- Cookie / secret files (NEVER commit) ----
vc.jar vc.jar
*.cookies *.cookies
*.session *.session
.env
.env.*
!.env.example
# ---- Frontend build products ---- # ---- Frontend build products ----
web/dist/ web/dist/
@@ -52,3 +56,4 @@ Thumbs.db
# ---- Claude Code / agent ---- # ---- Claude Code / agent ----
.claude/ .claude/
.worktrees/
+21 -8
View File
@@ -1,20 +1,25 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# ---- Stage 1: build the web frontend ---- # Build toolchains run natively on the BuildKit host. Without BUILDPLATFORM,
FROM node:20-alpine AS web-builder # the arm64 branch executes npm and the Go compiler through QEMU, which is much
# slower and makes npm ci appear to hang despite producing no progress output.
# ---- Stage 1: build the web frontend once on the native builder ----
FROM --platform=$BUILDPLATFORM node:20-alpine AS web-builder
WORKDIR /web WORKDIR /web
COPY web/package.json web/package-lock.json* ./ COPY web/package.json web/package-lock.json* ./
RUN npm ci RUN npm ci
COPY web/ ./ COPY web/ ./
RUN npm run build RUN npm run build
# ---- Stage 2: build the Go binary ---- # ---- Stage 2: cross-compile the Go binary on the native builder ----
FROM golang:1.25-alpine AS go-builder FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS go-builder
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /src WORKDIR /src
ARG VERSION=0.1.0-dev ARG VERSION=0.1.0-dev
ARG BUILD_TIME="" ARG BUILD_TIME=""
ARG TARGETOS
ARG TARGETARCH
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -23,7 +28,7 @@ COPY . .
# Overlay the freshly built frontend so go:embed web/dist picks it up. # Overlay the freshly built frontend so go:embed web/dist picks it up.
COPY --from=web-builder /web/dist ./web/dist COPY --from=web-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build \ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
-trimpath \ -trimpath \
-ldflags "-s -w -X vocat/internal/buildinfo.Version=${VERSION} -X vocat/internal/buildinfo.BuildTime=${BUILD_TIME}" \ -ldflags "-s -w -X vocat/internal/buildinfo.Version=${VERSION} -X vocat/internal/buildinfo.BuildTime=${BUILD_TIME}" \
-o /out/vocat \ -o /out/vocat \
@@ -31,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
# ---- Stage 3: minimal runtime ---- # ---- Stage 3: minimal runtime ----
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \ RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \
addgroup -S -g 1000 vocat && \ addgroup -S -g 1000 vocat && \
adduser -S -D -H -u 1000 -G vocat vocat adduser -S -D -H -u 1000 -G vocat vocat
@@ -39,11 +44,19 @@ RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \
chown -R vocat:vocat /opt/vocat chown -R vocat:vocat /opt/vocat
COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat
COPY scripts/docker-entrypoint.sh /usr/local/bin/vocat-entrypoint
USER vocat # Symlink into /usr/local/bin so `docker exec <ctr> vocat ...` finds it via $PATH.
RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat && \
chmod 0755 /usr/local/bin/vocat-entrypoint
# Hardware access and the bundled pcscd daemon require root inside the
# container. The container already needs host networking and privileged device
# access for modem, QMI, IPsec, and hot-plug support.
USER root
VOLUME ["/opt/vocat/data"] VOLUME ["/opt/vocat/data"]
EXPOSE 7575 EXPOSE 7575
ENV VOCAT_ADDR=0.0.0.0:7575 \ ENV VOCAT_ADDR=0.0.0.0:7575 \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
ENTRYPOINT ["/opt/vocat/bin/vocat"] ENTRYPOINT ["/usr/local/bin/vocat-entrypoint"]
+21
View File
@@ -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.
+10
View File
@@ -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
+64 -17
View File
@@ -22,7 +22,7 @@
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white"> <img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p> </p>
**English** | [简体中文](docs/README.zh-CN.md) **English** | [العربية](docs/README.ar.md) | [简体中文](docs/README.zh-CN.md) | [繁體中文](docs/README.zh-TW.md) | [Français](docs/README.fr.md) | [Русский](docs/README.ru.md) | [Español](docs/README.es.md) | [日本語](docs/README.ja.md)
Vocat is an open-source web control panel and engineering toolkit for Quectel EC20/EC25-class cellular modems. It combines modem discovery, live radio status, AT and USSD terminals, SMS, WiFi Calling, eSIM management, network selection, proxy routing, notifications, audit logs, and release automation in one self-contained service. Vocat is an open-source web control panel and engineering toolkit for Quectel EC20/EC25-class cellular modems. It combines modem discovery, live radio status, AT and USSD terminals, SMS, WiFi Calling, eSIM management, network selection, proxy routing, notifications, audit logs, and release automation in one self-contained service.
@@ -46,7 +46,7 @@ The backend is written in Go, the interface is built with React and TypeScript,
| Card policy | ICCID-based WiFi Calling and flight-mode behavior with immediate policy application. | | Card policy | ICCID-based WiFi Calling and flight-mode behavior with immediate policy application. |
| Proxy routing | Upstream SOCKS routing, device bindings, country rules, TCP reachability checks, and UDP Associate checks for WiFi Calling data paths. | | Proxy routing | Upstream SOCKS routing, device bindings, country rules, TCP reachability checks, and UDP Associate checks for WiFi Calling data paths. |
| Notifications | New inbound SMS forwarding through Telegram, Bark, email, Pushplus, and signed webhooks. Each SMS is delivered as an individual notification. | | Notifications | New inbound SMS forwarding through Telegram, Bark, email, Pushplus, and signed webhooks. Each SMS is delivered as an individual notification. |
| Telegram bot | Device status, installed-profile listing and switching, WiFi Calling controls, SMS sending, timed dialing with automatic hang-up, call status, answer, and hang-up commands. Sensitive actions require administrator confirmation. | | Telegram bot | Device status, installed-profile listing and switching, WiFi Calling controls, and SMS sending. Sensitive actions require administrator confirmation. |
| Operations | Authentication, CSRF protection, access policies, audit events, live logs, log retention, health checks, responsive layout, dark mode, and English/Chinese application UI. | | Operations | Authentication, CSRF protection, access policies, audit events, live logs, log retention, health checks, responsive layout, dark mode, and English/Chinese application UI. |
| Distribution | Static Linux binaries, systemd installation script, self-update with SHA-256 verification, Docker image, GHCR publishing, and GitHub Actions release builds. | | Distribution | Static Linux binaries, systemd installation script, self-update with SHA-256 verification, Docker image, GHCR publishing, and GitHub Actions release builds. |
@@ -65,10 +65,24 @@ Available features depend on the module firmware, USB composition, SIM/eSIM capa
### One-click Linux installation ### One-click Linux installation
As root (including OpenWrt/Kwrt, where `sudo` is normally absent):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
From a normal user on a distribution with sudo:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
``` ```
Check the host's VoWiFi/XFRM prerequisites without installing VoCat:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
Install a specific version: Install a specific version:
```bash ```bash
@@ -76,6 +90,12 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
sudo bash install.sh 0.0.2 sudo bash install.sh 0.0.2
``` ```
VoWiFi IMS requires Linux XFRM/IPsec. On OpenWrt/Kwrt the installer attempts
to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
`kmod-crypto-authenc`, AES-CBC and SHA1 packages from the firmware's own feed.
If matching kernel modules are unavailable, use a firmware that includes them;
never force-install kmods built for a different kernel.
The installer: The installer:
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`; - detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
@@ -110,9 +130,11 @@ Verify and install it:
sha256sum -c SHA256SUMS --ignore-missing sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \ sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \ VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
VOCAT_ADMIN_PASSWORD=change-this-password \
/opt/vocat/bin/vocat serve /opt/vocat/bin/vocat serve
``` ```
@@ -129,13 +151,20 @@ continue seeing USB hot-plug events, run Vocat in hardware-access mode:
```bash ```bash
docker pull ghcr.io/mengmengcode/vocat:latest docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \ docker run -d \
--name vocat \ --name vocat \
--restart unless-stopped \ --restart unless-stopped \
--network host \ --network host \
--privileged \ --privileged \
--user 0:0 \ --user 0:0 \
-e VOCAT_ADMIN_PASSWORD=change-this-password \
-v vocat-data:/opt/vocat/data \ -v vocat-data:/opt/vocat/data \
-v /dev:/dev \ -v /dev:/dev \
-v /sys:/sys:ro \ -v /sys:/sys:ro \
@@ -146,18 +175,28 @@ Open `http://<server-address>:7575` after the container starts. Host networking
is required so QMI network interfaces remain visible to Vocat, while privileged is required so QMI network interfaces remain visible to Vocat, while privileged
device access is required for serial ports, QMI control nodes, TUN interfaces, device access is required for serial ports, QMI control nodes, TUN interfaces,
network configuration, and devices added after the container starts. The network configuration, and devices added after the container starts. The
`/dev` bind mount makes new `ttyUSB*`, `ttyACM*`, and `cdc-wdm*` nodes visible `/dev` bind mount makes new `ttyUSB*`, `ttyACM*`, `cdc-wdm*`, and MHI
without recreating the container. `wwan*` nodes visible without recreating the container.
This mode intentionally gives Vocat broad access to the host's devices and This mode intentionally gives Vocat broad access to the host's devices and
network stack. Use it only on a trusted Linux host. The automatic discovery network stack. Use it only on a trusted Linux host. The automatic discovery
currently identifies supported Quectel USB modems (USB vendor ID `2c7c`), not identifies supported Quectel USB modems (USB vendor ID `2c7c`) and PCIe/MHI
arbitrary modem brands. Mapping only individual nodes with `--device`, such as modems exposed through the Linux WWAN subsystem; it does not identify arbitrary
`/dev/ttyUSB2` and `/dev/cdc-wdm0`, limits the container to those fixed nodes modem layouts. Mapping only individual nodes with `--device`, such as
and does not provide complete multi-device or hot-plug discovery. `/dev/ttyUSB2`, `/dev/cdc-wdm0`, or `/dev/wwan0qmi0`, limits the container to
those fixed nodes and does not provide complete multi-device or hot-plug discovery.
The GHCR image is published for `linux/amd64` and `linux/arm64`. The GHCR image is published for `linux/amd64` and `linux/arm64`.
### USB SIM readers
USB SIM readers use the Linux PC/SC service. The one-click installer installs
and starts `pcscd` plus the CCID driver automatically on supported package
managers. On Debian/Ubuntu, the equivalent manual setup is
`apt install pcscd libccid`. If USB sees a CCID reader but PC/SC is unavailable,
VoCat keeps the reader visible in the add-device dialog and reports the missing
service or driver instead of silently hiding it.
## Configuration ## Configuration
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence. Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
@@ -166,8 +205,6 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
| --- | --- | --- | | --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP listen address. | | `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP listen address. |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite database path. | | `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite database path. |
| `VOCAT_ADMIN_USERNAME` | `admin` | Initial administrator username. |
| `VOCAT_ADMIN_PASSWORD` | `admin` | Initial administrator password. Change it before exposing the service. |
| `VOCAT_SESSION_TTL` | `24h` | Authentication session lifetime. | | `VOCAT_SESSION_TTL` | `24h` | Authentication session lifetime. |
| `VOCAT_SECURE_COOKIES` | `false` | Marks session cookies as secure when HTTPS is used. | | `VOCAT_SECURE_COOKIES` | `false` | Marks session cookies as secure when HTTPS is used. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown timeout. | | `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown timeout. |
@@ -175,6 +212,10 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. | | `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. | | `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
Administrator credentials are stored only in SQLite. Initialize an empty
database once with `vocat bootstrap-admin`; environment variables and JSON
configuration cannot set or overwrite the administrator username or password.
Do not store Telegram tokens, SMTP passwords, webhook secrets, SIM credentials, or other private data in the repository. Configure them through the application settings or protected environment files. Do not store Telegram tokens, SMTP passwords, webhook secrets, SIM credentials, or other private data in the repository. Configure them through the application settings or protected environment files.
## Telegram bot ## Telegram bot
@@ -187,13 +228,9 @@ When Telegram notifications are enabled and both Chat ID and Admin ID are config
/switch <device> <iccid> /switch <device> <iccid>
/wfc <device> <status|on|off|reconnect> /wfc <device> <status|on|off|reconnect>
/sms <device> <number> <message> /sms <device> <number> <message>
/call <device> <number> <seconds>
/calls <device>
/answer <device>
/hangup <device>
``` ```
Profile switching, SMS submission, and dialing use one-time confirmation buttons. Timed dialing performs the modem call action and automatically hangs up after 1600 seconds; it does not capture or process call audio. The bot does not expose eSIM download, delete, or rename commands. Profile switching and SMS submission use one-time confirmation buttons. The bot does not expose eSIM download, delete, or rename commands.
## Updating ## Updating
@@ -305,6 +342,16 @@ cd web && npm run build
- [Linux.do](https://linux.do) — An inspiring tech community - [Linux.do](https://linux.do) — An inspiring tech community
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines - [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
## Buy me a coffee
| Network | Address |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## License ## License
See [LICENSE](LICENSE). See [LICENSE](LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"vocat/internal/auth"
"vocat/internal/store"
)
func runBootstrapAdmin(args []string) error {
flags := flag.NewFlagSet("bootstrap-admin", flag.ContinueOnError)
flags.SetOutput(io.Discard)
databasePath := flags.String("database", "/opt/vocat/data/vocat.db", "database path")
username := flags.String("username", "admin", "administrator username")
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
}
reader := bufio.NewReader(os.Stdin)
password, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("read password: %w", err)
}
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
if password == "" {
return errors.New("bootstrap password cannot be empty")
}
adminUsername := strings.TrimSpace(*username)
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
return errors.New("bootstrap username must contain between 1 and 64 characters without control whitespace")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
database, err := store.Open(ctx, strings.TrimSpace(*databasePath))
if err != nil {
return err
}
defer database.Close()
service, err := auth.New(database, auth.Options{SessionTTL: 24 * time.Hour})
if err != nil {
return err
}
created, err := service.EnsureAdminIfMissing(ctx, adminUsername, password)
if err != nil {
return err
}
if created {
fmt.Println("created")
} else {
fmt.Println("exists")
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"vocat/internal/auth"
"vocat/internal/store"
)
func TestBootstrapAdminOnlyInitializesAnEmptyDatabase(t *testing.T) {
path := filepath.Join(t.TempDir(), "vocat.db")
withBootstrapStdin(t, "first-secure-password\n", func() {
if err := runBootstrapAdmin([]string{"--database", path}); err != nil {
t.Fatal(err)
}
})
withBootstrapStdin(t, "second-secure-password\n", func() {
if err := runBootstrapAdmin([]string{"--database", path}); err != nil {
t.Fatal(err)
}
})
database, err := store.Open(context.Background(), path)
if err != nil {
t.Fatal(err)
}
defer database.Close()
service, err := auth.New(database, auth.Options{SessionTTL: time.Hour})
if err != nil {
t.Fatal(err)
}
if _, err := service.Login(context.Background(), "admin", "first-secure-password"); err != nil {
t.Fatalf("initial password was overwritten: %v", err)
}
}
func withBootstrapStdin(t *testing.T, input string, action func()) {
t.Helper()
original := os.Stdin
file, err := os.CreateTemp(t.TempDir(), "stdin")
if err != nil {
t.Fatal(err)
}
if _, err := file.WriteString(input); err != nil {
t.Fatal(err)
}
if _, err := file.Seek(0, 0); err != nil {
t.Fatal(err)
}
os.Stdin = file
t.Cleanup(func() { os.Stdin = original; _ = file.Close() })
action()
os.Stdin = original
}
+5 -2
View File
@@ -21,6 +21,9 @@ Usage:
vocat serve Run the server in the foreground (use from a TTY when vocat serve Run the server in the foreground (use from a TTY when
vocat without arguments would enter the menu). vocat without arguments would enter the menu).
vocat version Print the build version and exit. vocat version Print the build version and exit.
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths.
Use --repair-dji-qmi on Linux to safely wake a factory-ID
DJI/Baiwang 2ca3:4006 QMI interface without changing NV.
vocat update Check GitHub for a newer release and self-update. vocat update Check GitHub for a newer release and self-update.
Flags: Flags:
--check Only report whether an update is available. --check Only report whether an update is available.
@@ -32,8 +35,8 @@ Usage:
GITHUB_TOKEN Optional bearer token for private repos GITHUB_TOKEN Optional bearer token for private repos
or higher rate limits. or higher rate limits.
vocat menu Interactive lifecycle menu (root on the host): vocat menu Interactive lifecycle menu (root on the host):
toggle language, change password, restart, update, toggle language, change password, change the Web port,
uninstall. restart, update, uninstall.
vocat help Show this help message. vocat help Show this help message.
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
+8 -16
View File
@@ -6,11 +6,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"strings" "strings"
"time" "time"
"vocat/internal/config" "vocat/internal/config"
"vocat/internal/developer"
"vocat/internal/store" "vocat/internal/store"
) )
@@ -18,7 +18,7 @@ import (
// plugin/extension system. When absent the developer mode defaults to off, so // plugin/extension system. When absent the developer mode defaults to off, so
// a fresh install exposes no plugin surface until an operator explicitly turns // a fresh install exposes no plugin surface until an operator explicitly turns
// it on with `vocat develop on` and restarts the service. // it on with `vocat develop on` and restarts the service.
const developerEnabledSettingKey = "developer.enabled" const developerEnabledSettingKey = developer.EnabledSettingKey
// runDevelop handles the hidden `vocat develop on|off` subcommand. It is // runDevelop handles the hidden `vocat develop on|off` subcommand. It is
// intentionally excluded from printUsage and the interactive menu: the plugin // intentionally excluded from printUsage and the interactive menu: the plugin
@@ -65,6 +65,11 @@ func runDevelop(args []string, logger *slog.Logger) error {
}); err != nil { }); err != nil {
return fmt.Errorf("persist developer flag: %w", err) return fmt.Errorf("persist developer flag: %w", err)
} }
if !enabled {
if err := developer.ResetExperimental(ctx, database); err != nil {
return fmt.Errorf("reset developer settings: %w", err)
}
}
if enabled { if enabled {
fmt.Printf("开发者模式已开启。重启 vocat 服务后插件功能生效。\n数据库:%s\n", cfg.DatabasePath) fmt.Printf("开发者模式已开启。重启 vocat 服务后插件功能生效。\n数据库:%s\n", cfg.DatabasePath)
@@ -91,18 +96,5 @@ func parseDevelopArg(arg string) (bool, bool) {
// or an unparseable value resolves to false — the system defaults closed, so // or an unparseable value resolves to false — the system defaults closed, so
// any read failure keeps plugins off rather than exposing them by accident. // any read failure keeps plugins off rather than exposing them by accident.
func isDeveloperEnabled(ctx context.Context, database *store.Store) bool { func isDeveloperEnabled(ctx context.Context, database *store.Store) bool {
setting, err := database.AppSetting(ctx, developerEnabledSettingKey) return developer.Enabled(ctx, database)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
fmt.Fprintf(os.Stderr, "vocat: read developer flag failed; plugin system stays off: %v\n", err)
}
return false
}
var document struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(setting.Value, &document); err != nil {
return false
}
return document.Enabled
} }
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"
"vocat/internal/modem"
"vocat/internal/pcsc"
"vocat/internal/proxy"
)
type doctorCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Evidence any `json:"evidence,omitempty"`
}
type doctorReport struct {
Time time.Time `json:"time"`
OS string `json:"os"`
Arch string `json:"arch"`
Checks []doctorCheck `json:"checks"`
}
type djiQMIRepairResult struct {
USBName string `json:"usb_name"`
Interface string `json:"interface"`
USBDevice string `json:"usb_device"`
OriginalDriver string `json:"original_driver,omitempty"`
ControlDevice string `json:"control_device"`
NetworkInterface string `json:"network_interface,omitempty"`
QMIProbe string `json:"qmi_probe"`
Attempts int `json:"attempts"`
}
func runDoctor(args []string) error {
flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
flags.SetOutput(os.Stderr)
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password")
repairDJI := flags.Bool("repair-dji-qmi", false, "rebind DJI 2ca3:4006 interface 4 to qmi_wwan and assert DTR (Linux/root only; no NV write)")
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if flags.NArg() != 0 || *timeout <= 0 || *timeout > time.Minute {
return errors.New("usage: vocat doctor [--repair-dji-qmi] [--proxy host:port] [--proxy-username name] [--proxy-password-env ENV] [--json]")
}
report := doctorReport{Time: time.Now().UTC(), OS: runtime.GOOS, Arch: runtime.GOARCH}
add := func(name, status, code, message string, evidence any) {
report.Checks = append(report.Checks, doctorCheck{Name: name, Status: status, Code: code, Message: message, Evidence: evidence})
}
if data, err := os.ReadFile("/proc/version"); err == nil && strings.Contains(strings.ToLower(string(data)), "microsoft") {
add("host", "warning", "wsl_usbip_detected", "WSL/USBIP detected; QMI control transfers may time out even when /dev/cdc-wdm exists", nil)
} else {
add("host", "passed", "native_host", "No WSL kernel marker detected", nil)
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
if *repairDJI {
result, err := repairDJIQMI(ctx)
if err != nil {
return fmt.Errorf("repair DJI QMI binding: %w", err)
}
add("dji_qmi_repair", "passed", "dji_qmi_dtr_asserted", "DJI interface 4 was bound to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result)
}
candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx)
if discoverErr != nil {
add("modem_discovery", "failed", "modem_discovery_failed", discoverErr.Error(), nil)
} else if len(candidates) == 0 {
add("modem_discovery", "warning", "no_modem", "No USB modem was discovered", nil)
} else {
add("modem_discovery", "passed", "modem_discovered", fmt.Sprintf("Discovered %d modem candidate(s)", len(candidates)), candidates)
}
for _, candidate := range candidates {
name := "modem:" + candidate.ID
if candidate.HasATPort() {
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 5*time.Second))
client, openErr := (modem.SerialOpener{}).Open(probeContext, candidate.ATPort)
if openErr != nil {
add(name+":at", "warning", "at_open_failed", openErr.Error(), candidate.ATPort.OpenPath())
} else {
response, commandErr := client.Execute(probeContext, "AT+CFUN?")
_ = client.Close()
if commandErr != nil {
add(name+":at", "warning", "at_probe_failed", commandErr.Error(), candidate.ATPort.OpenPath())
} else {
add(name+":at", "passed", "at_ready", "AT control channel responded to a read-only CFUN query", response.Text())
}
}
cancelProbe()
} else {
add(name+":at", "failed", "at_missing", "No AT port was selected", nil)
}
if strings.TrimSpace(candidate.QMIControl) == "" {
add(name+":qmi", "warning", "qmi_missing", "No cdc-wdm/QMI control node was discovered", nil)
} else if qmicli, lookErr := exec.LookPath("qmicli"); lookErr != nil {
add(name+":qmi", "warning", "qmicli_missing", "QMI node exists but qmicli is unavailable for an active DMS check", candidate.QMIControl)
} else {
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 8*time.Second))
command := exec.CommandContext(probeContext, qmicli, "-d", candidate.QMIControl, "--dms-get-operating-mode")
output, commandErr := command.CombinedOutput()
message := strings.TrimSpace(string(output))
cancelProbe()
if commandErr != nil {
code := "qmi_cid_failed"
if errors.Is(probeContext.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timed out") {
code = "qmi_cid_timeout"
}
add(name+":qmi", "failed", code, "qmicli DMS client allocation/read failed", message)
} else {
add(name+":qmi", "passed", "qmi_dms_ready", "qmicli allocated DMS and completed a read-only request", message)
}
}
}
readers, readerErr := pcsc.New().Readers(ctx)
if readerErr == nil {
add("pcsc", "passed", "pcsc_ready", fmt.Sprintf("PC/SC reported %d reader(s)", len(readers)), readers)
} else if errors.Is(readerErr, pcsc.ErrUnsupported) || errors.Is(readerErr, pcsc.ErrUnavailable) {
add("pcsc", "warning", "pcsc_unavailable", readerErr.Error(), nil)
} else {
add("pcsc", "failed", "pcsc_failed", readerErr.Error(), nil)
}
if strings.TrimSpace(*proxyAddress) != "" {
password := os.Getenv(strings.TrimSpace(*passwordEnv))
probeContext, cancelProbe := context.WithTimeout(context.Background(), *timeout)
result, probeErr := proxy.ProbeSOCKS5(probeContext, *proxyAddress, *proxyUsername, password, *timeout)
cancelProbe()
status := "passed"
if probeErr != nil {
status = "failed"
}
add("proxy_udp", status, result.Diagnosis, result.Hint, result)
}
if *jsonOutput {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(report)
}
for _, check := range report.Checks {
fmt.Printf("%-8s %-26s %-28s %s\n", strings.ToUpper(check.Status), check.Name, check.Code, check.Message)
}
return nil
}
func minDuration(left, right time.Duration) time.Duration {
if left < right {
return left
}
return right
}
+298
View File
@@ -0,0 +1,298 @@
//go:build linux
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/unix"
)
const (
djiVendorID = "2ca3"
djiProductID = "4006"
djiQMIIndex = 4
)
type usbControlTransfer struct {
RequestType uint8
Request uint8
Value uint16
Index uint16
Length uint16
Timeout uint32
Data uintptr
}
func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) {
return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) {
return repairDJIQMIAt(attemptContext, "/sys", "/dev")
})
}
func retryDJIQMI(
ctx context.Context,
maxAttempts int,
delay time.Duration,
attempt func(context.Context) (djiQMIRepairResult, error),
) (djiQMIRepairResult, error) {
var result djiQMIRepairResult
var err error
for attemptNumber := 1; attemptNumber <= maxAttempts; attemptNumber++ {
result, err = attempt(ctx)
result.Attempts = attemptNumber
if err == nil {
return result, nil
}
if ctx.Err() != nil {
break
}
timer := time.NewTimer(time.Duration(attemptNumber) * delay)
select {
case <-ctx.Done():
timer.Stop()
return result, errors.Join(err, ctx.Err())
case <-timer.C:
}
}
return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err)
}
func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMIRepairResult, returnErr error) {
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
entries, err := os.ReadDir(usbRoot)
if err != nil {
return result, fmt.Errorf("read USB topology: %w", err)
}
var usbNames []string
for _, entry := range entries {
devicePath := filepath.Join(usbRoot, entry.Name())
vendor, vendorErr := readTrimmedFile(filepath.Join(devicePath, "idVendor"))
product, productErr := readTrimmedFile(filepath.Join(devicePath, "idProduct"))
if vendorErr == nil && productErr == nil &&
strings.EqualFold(vendor, djiVendorID) && strings.EqualFold(product, djiProductID) {
usbNames = append(usbNames, entry.Name())
}
}
if len(usbNames) != 1 {
return result, fmt.Errorf("expected exactly one DJI %s:%s USB device, found %d", djiVendorID, djiProductID, len(usbNames))
}
result.USBName = usbNames[0]
result.Interface = fmt.Sprintf("%s:1.%d", result.USBName, djiQMIIndex)
devicePath := filepath.Join(usbRoot, result.USBName)
interfacePath := filepath.Join(usbRoot, result.Interface)
if _, err := os.Stat(interfacePath); err != nil {
return result, fmt.Errorf("DJI QMI interface %s unavailable: %w", result.Interface, err)
}
busNumber, err := readUSBNumber(filepath.Join(devicePath, "busnum"))
if err != nil {
return result, err
}
deviceNumber, err := readUSBNumber(filepath.Join(devicePath, "devnum"))
if err != nil {
return result, err
}
result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber))
result.OriginalDriver = usbInterfaceDriver(interfacePath)
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver)
}
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
if _, err := os.Stat(filepath.Join(driversRoot, "qmi_wwan")); err != nil {
modprobe, lookErr := exec.LookPath("modprobe")
if lookErr != nil {
return result, errors.New("qmi_wwan is not loaded and modprobe is unavailable")
}
if output, loadErr := exec.CommandContext(ctx, modprobe, "qmi_wwan").CombinedOutput(); loadErr != nil {
return result, fmt.Errorf("load qmi_wwan: %w: %s", loadErr, strings.TrimSpace(string(output)))
}
}
interfaceDetached := false
restoreOriginal := func() {
if !interfaceDetached {
return
}
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
}
if result.OriginalDriver != "" {
_ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface)
}
}
defer func() {
if returnErr != nil {
restoreOriginal()
}
}()
if result.OriginalDriver != "" {
if err := writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "unbind"), result.Interface); err != nil {
return result, fmt.Errorf("unbind %s from %s: %w", result.OriginalDriver, result.Interface, err)
}
interfaceDetached = true
}
if err := assertUSBDTR(result.USBDevice, djiQMIIndex); err != nil {
return result, err
}
bindPath := filepath.Join(driversRoot, "qmi_wwan", "bind")
if err := writeSysfs(bindPath, result.Interface); err != nil {
newIDErr := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "new_id"), djiVendorID+" "+djiProductID)
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
return result, fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
}
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
if retryErr := writeSysfs(bindPath, result.Interface); retryErr != nil {
return result, fmt.Errorf("bind qmi_wwan to %s: %w", result.Interface, retryErr)
}
}
}
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
return result, fmt.Errorf("interface %s driver is %q after qmi_wwan bind", result.Interface, driver)
}
deadline := time.Now().Add(2 * time.Second)
for {
result.ControlDevice = firstDeviceNode(filepath.Join(interfacePath, "usbmisc"), devRoot, "cdc-wdm")
result.NetworkInterface = firstEntryName(filepath.Join(interfacePath, "net"), "")
if result.ControlDevice != "" {
break
}
if err := ctx.Err(); err != nil {
return result, err
}
if time.Now().After(deadline) {
return result, fmt.Errorf("qmi_wwan bound but no cdc-wdm node appeared for %s", result.Interface)
}
time.Sleep(25 * time.Millisecond)
}
time.Sleep(250 * time.Millisecond)
qmicli, err := exec.LookPath("qmicli")
if err != nil {
return result, errors.New("qmicli is required to verify DJI QMI readiness after DTR repair")
}
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
cancelProbe()
result.QMIProbe = strings.TrimSpace(string(output))
if probeErr != nil {
if probeContext.Err() != nil {
probeErr = errors.Join(probeErr, probeContext.Err())
}
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
}
interfaceDetached = false
return result, nil
}
func assertUSBDTR(devicePath string, interfaceIndex int) error {
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
if err != nil {
return fmt.Errorf("open USB device %s: %w", devicePath, err)
}
defer unix.Close(fd)
if err := setUSBControlLineState(fd, interfaceIndex, false); err != nil {
return fmt.Errorf("clear CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
}
time.Sleep(50 * time.Millisecond)
if err := setUSBControlLineState(fd, interfaceIndex, true); err != nil {
return fmt.Errorf("assert CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
}
// QDC507 acknowledges the control transfer before its QMI firmware is ready.
time.Sleep(time.Second)
return nil
}
func setUSBControlLineState(fd, interfaceIndex int, dtr bool) error {
var value uint16
if dtr {
value = 1 // USB_CDC_CTRL_DTR
}
transfer := usbControlTransfer{
RequestType: 0x21, // host-to-device, class, interface
Request: 0x22, // USB_CDC_REQ_SET_CONTROL_LINE_STATE
Value: value,
Index: uint16(interfaceIndex),
Timeout: 5000,
}
const ioctlDirectionReadWrite = uintptr(3)
request := ioctlDirectionReadWrite<<30 |
uintptr(unsafe.Sizeof(transfer))<<16 |
uintptr('U')<<8
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), request, uintptr(unsafe.Pointer(&transfer)))
if errno != 0 {
return errno
}
return nil
}
func readTrimmedFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func readUSBNumber(path string) (int, error) {
value, err := readTrimmedFile(path)
if err != nil {
return 0, fmt.Errorf("read %s: %w", filepath.Base(path), err)
}
number, err := strconv.Atoi(value)
if err != nil || number < 1 || number > 999 {
return 0, fmt.Errorf("invalid %s %q", filepath.Base(path), value)
}
return number, nil
}
func usbInterfaceDriver(interfacePath string) string {
resolved, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver"))
if err != nil {
return ""
}
return filepath.Base(resolved)
}
func writeSysfs(path, value string) error {
file, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
return err
}
_, writeErr := file.WriteString(value)
closeErr := file.Close()
return errors.Join(writeErr, closeErr)
}
func firstDeviceNode(directory, devRoot, prefix string) string {
name := firstEntryName(directory, prefix)
if name == "" {
return ""
}
return filepath.Join(devRoot, name)
}
func firstEntryName(directory, prefix string) string {
entries, err := os.ReadDir(directory)
if err != nil {
return ""
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
return entry.Name()
}
}
return ""
}
+81
View File
@@ -0,0 +1,81 @@
//go:build linux
package main
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"unsafe"
)
func TestDJIUSBControlTransferLayout(t *testing.T) {
var transfer usbControlTransfer
if got := unsafe.Sizeof(transfer); got != 24 {
t.Fatalf("usbControlTransfer size = %d, want 24", got)
}
if transfer.RequestType != 0 || transfer.Request != 0 {
t.Fatal("zero-value transfer unexpectedly initialized")
}
}
func TestReadUSBNumber(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "busnum")
if err := os.WriteFile(path, []byte("12\n"), 0o600); err != nil {
t.Fatal(err)
}
if got, err := readUSBNumber(path); err != nil || got != 12 {
t.Fatalf("readUSBNumber() = %d, %v, want 12, nil", got, err)
}
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readUSBNumber(path); err == nil {
t.Fatal("readUSBNumber(0) unexpectedly succeeded")
}
}
func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing")
if err := writeSysfs(path, "value"); err == nil {
t.Fatal("writeSysfs(missing) unexpectedly succeeded")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("missing sysfs path was created: %v", err)
}
}
func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) {
attempts := 0
result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
attempts++
if attempts < 3 {
return djiQMIRepairResult{}, errors.New("transient QMI timeout")
}
return djiQMIRepairResult{ControlDevice: "/dev/cdc-wdm0"}, nil
})
if err != nil {
t.Fatalf("retryDJIQMI() error = %v", err)
}
if attempts != 3 || result.Attempts != 3 {
t.Fatalf("attempts = %d, result.Attempts = %d, want 3", attempts, result.Attempts)
}
}
func TestRetryDJIQMIStopsAfterBoundedAttempts(t *testing.T) {
attempts := 0
_, err := retryDJIQMI(context.Background(), 2, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
attempts++
return djiQMIRepairResult{}, errors.New("persistent failure")
})
if err == nil {
t.Fatal("retryDJIQMI() unexpectedly succeeded")
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
package main
import (
"context"
"errors"
)
func repairDJIQMI(context.Context) (djiQMIRepairResult, error) {
return djiQMIRepairResult{}, errors.New("DJI QMI repair is supported only on Linux")
}
+9
View File
@@ -0,0 +1,9 @@
package main
import "testing"
func TestDoctorHelpIsSuccessful(t *testing.T) {
if err := runDoctor([]string{"--help"}); err != nil {
t.Fatalf("runDoctor(--help) error = %v", err)
}
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"os"
"strings"
"testing"
)
func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
if err != nil {
t.Fatal(err)
}
script := string(scriptBytes)
mainStart := strings.LastIndex(script, "# --- Main ")
if mainStart < 0 {
t.Fatal("installer main section not found")
}
main := script[mainStart:]
validateAt := strings.Index(main, `bootstrap_admin "${VOCAT_TMP}/vocat"`)
installAt := strings.Index(main, "install_binary")
if validateAt < 0 {
t.Fatal("installer does not validate the database with the downloaded binary")
}
if installAt < 0 {
t.Fatal("installer does not install the downloaded binary")
}
if validateAt > installAt {
t.Fatal("installer replaces the current binary before validating database compatibility")
}
}
+45
View File
@@ -0,0 +1,45 @@
//go:build linux
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
func lockServerInstance(databasePath string) (*os.File, error) {
// The modem, PC/SC reader, XFRM policies and listener are host resources,
// not database resources. Lock per OS user so a diagnostic instance using a
// different VOCAT_DATABASE_PATH cannot silently steal the same AT port from
// the managed service. Prefer /run because systemd's PrivateTmp would
// otherwise hide the managed service's lock from a manually started process.
// The UID-specific directory still permits intentionally isolated users to
// operate independently; development hosts without writable /run fall back
// to TempDir.
uid := os.Geteuid()
directory := filepath.Join("/run", fmt.Sprintf("vocat-%d", uid))
if uid == 0 {
directory = "/run/vocat"
}
if err := os.MkdirAll(directory, 0o700); err != nil {
directory = os.TempDir()
}
path := filepath.Join(directory, "vocat-server.lock")
fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if err != nil {
return nil, fmt.Errorf("open server instance lock: %w", err)
}
file := os.NewFile(uintptr(fd), path)
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return nil, errors.New("another vocat server already controls this host's modem resources")
}
return nil, fmt.Errorf("lock server instance: %w", err)
}
return file, nil
}
+26
View File
@@ -0,0 +1,26 @@
//go:build linux
package main
import (
"path/filepath"
"strings"
"testing"
)
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
firstDatabase := filepath.Join(t.TempDir(), "vocat.db")
first, err := lockServerInstance(firstDatabase)
if err != nil {
t.Fatal(err)
}
defer first.Close()
secondDatabase := filepath.Join(t.TempDir(), "other.db")
second, err := lockServerInstance(secondDatabase)
if second != nil {
second.Close()
}
if err == nil || !strings.Contains(err.Error(), "already controls this host") {
t.Fatalf("second lock error = %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
package main
import (
"os"
"path/filepath"
)
func lockServerInstance(databasePath string) (*os.File, error) {
return os.OpenFile(filepath.Join(filepath.Dir(databasePath), ".vocat.lock"), os.O_CREATE|os.O_RDWR, 0o600)
}
+703 -79
View File
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -152,11 +152,7 @@ func TestEnforceCardRegionSkipsRadioWhenAlreadyOff(t *testing.T) {
} }
func TestEnforceCardRegionLiftsBlockForAllowedSIM(t *testing.T) { func TestEnforceCardRegionLiftsBlockForAllowedSIM(t *testing.T) {
client := &fakeModemClient{steps: []fakeStep{ client := &fakeModemClient{}
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
{command: "AT+CFUN=1"},
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
}}
manager := newRegionTestManager(t, client) manager := newRegionTestManager(t, client)
database := newRegionTestStore(t) database := newRegionTestStore(t)
@@ -221,3 +217,24 @@ func TestEnforceCardRegionIgnoresUnknownOrNotReadySIM(t *testing.T) {
t.Fatalf("expected no card policies, got %d", len(policies)) t.Fatalf("expected no card policies, got %d", len(policies))
} }
} }
func TestProvisionedDeviceTypeRecognizesNativeWWAN(t *testing.T) {
native := modem.Candidate{
HardwareKind: "wwan",
USBPath: "/sys/devices/pci0000:00/0000:00:00.0/wwan/wwan0",
QMIControl: "/dev/wwan0qmi0",
ATPort: modem.Port{Path: "/dev/wwan0at0"},
}
if got := provisionedDeviceType(native); got != store.DeviceTypeWiFi410 {
t.Fatalf("native WWAN type = %q, want %q", got, store.DeviceTypeWiFi410)
}
usb := modem.Candidate{
USBPath: "/sys/bus/usb/devices/1-6",
QMIControl: "/dev/cdc-wdm0",
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
}
if got := provisionedDeviceType(usb); got != store.DeviceTypePCIeEC20EC25 {
t.Fatalf("USB modem type = %q, want %q", got, store.DeviceTypePCIeEC20EC25)
}
}
+294 -89
View File
@@ -7,8 +7,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"time" "time"
@@ -20,11 +22,15 @@ import (
"vocat/internal/update" "vocat/internal/update"
) )
// envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD. // envFilePath carries non-secret service settings such as the Web listen port.
// EnsureAdmin reseeds the DB from it on every start, so change-password must // Administrator credentials live exclusively in the database.
// rewrite it or the next restart reverts the password.
const envFilePath = "/etc/vocat/env" const envFilePath = "/etc/vocat/env"
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
// discoverable so the menu works on installations made before the installer
// and service template converged on /etc/vocat/env.
const legacyEnvFilePath = "/etc/vocat/vocat.env"
const systemdUnitPath = "/etc/systemd/system/vocat.service" const systemdUnitPath = "/etc/systemd/system/vocat.service"
// defaultDatabasePath is the install-default SQLite location written into the // defaultDatabasePath is the install-default SQLite location written into the
@@ -44,13 +50,13 @@ const uiPreferencesSettingKey = "ui.preferences"
// rc) and VOCAT_DATABASE_PATH is unset, so config.Load() would resolve a // rc) and VOCAT_DATABASE_PATH is unset, so config.Load() would resolve a
// CWD-relative ./data/vocat.db — a different, empty database than // CWD-relative ./data/vocat.db — a different, empty database than
// /opt/vocat/data/vocat.db the service uses. This loads the installed env file // /opt/vocat/data/vocat.db the service uses. This loads the installed env file
// for VOCAT_ADMIN_PASSWORD and pins VOCAT_DATABASE_PATH to the install default, // and pins VOCAT_DATABASE_PATH to the install default, without overriding any
// without overriding any value the operator already exported. // value the operator already exported. Legacy credential entries are ignored.
func loadMenuEnv() { func loadMenuEnv() {
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok { if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath) _ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
} }
if data, err := os.ReadFile(envFilePath); err == nil { if data, err := os.ReadFile(menuEnvFilePath()); err == nil {
for _, line := range strings.Split(string(data), "\n") { for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@@ -61,6 +67,9 @@ func loadMenuEnv() {
continue continue
} }
key := strings.TrimSpace(line[:eq]) key := strings.TrimSpace(line[:eq])
if key == "VOCAT_ADMIN_USERNAME" || key == "VOCAT_ADMIN_PASSWORD" || key == "VOCAT_ADMIN_PASSWORD_B64" {
continue
}
val := strings.TrimSpace(line[eq+1:]) val := strings.TrimSpace(line[eq+1:])
if _, ok := os.LookupEnv(key); !ok { if _, ok := os.LookupEnv(key); !ok {
_ = os.Setenv(key, val) _ = os.Setenv(key, val)
@@ -69,10 +78,20 @@ func loadMenuEnv() {
} }
} }
// runMenu is the interactive lifecycle menu: toggle language, change password, func menuEnvFilePath() string {
// restart the systemd unit, self-update, or fully uninstall vocat. It must run if _, err := os.Stat(envFilePath); err == nil {
// as root on the host (needs systemctl + the 0600 env file). Docker deployments return envFilePath
// do not use it. }
if _, err := os.Stat(legacyEnvFilePath); err == nil {
return legacyEnvFilePath
}
return envFilePath
}
// runMenu is the interactive lifecycle menu: toggle language, reset credentials,
// change the Web listener port, restart the systemd unit, self-update, or fully
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
// env file). Docker deployments do not use it.
func runMenu(logger *slog.Logger) error { func runMenu(logger *slog.Logger) error {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)") return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
@@ -109,14 +128,18 @@ func runMenu(logger *slog.Logger) error {
fmt.Println(menu.errorPrefix(err)) fmt.Println(menu.errorPrefix(err))
} }
case "2": case "2":
if err := menuChangePassword(reader, menu, logger); err != nil { if err := menuResetAdminCredentials(reader, menu); err != nil {
fmt.Println(menu.errorPrefix(err)) fmt.Println(menu.errorPrefix(err))
} }
case "3": case "3":
if err := menuRestart(menu); err != nil { if err := menuChangeWebPort(reader, menu); err != nil {
fmt.Println(menu.errorPrefix(err)) fmt.Println(menu.errorPrefix(err))
} }
case "4": case "4":
if err := menuRestart(menu); err != nil {
fmt.Println(menu.errorPrefix(err))
}
case "5":
if err := menuUpdate(menu, logger); err != nil { if err := menuUpdate(menu, logger); err != nil {
fmt.Println(menu.errorPrefix(err)) fmt.Println(menu.errorPrefix(err))
} }
@@ -170,13 +193,12 @@ func loadMenuLanguage() (string, error) {
return "en", nil return "en", nil
} }
func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error { func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err) return fmt.Errorf("%w: %v", errMenuConfig, err)
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx := context.Background()
defer cancel()
database, err := store.Open(ctx, cfg.DatabasePath) database, err := store.Open(ctx, cfg.DatabasePath)
if err != nil { if err != nil {
@@ -188,11 +210,18 @@ func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) erro
if err != nil { if err != nil {
return fmt.Errorf("%w: %v", errMenuAuth, err) return fmt.Errorf("%w: %v", errMenuAuth, err)
} }
admin, err := database.CurrentAdmin(ctx)
fmt.Print(m.currentPassword())
currentPw, err := readPasswordMasked()
if err != nil { if err != nil {
return err return fmt.Errorf("%w: %v", errMenuStore, err)
}
fmt.Print(m.newUsername(admin.Username))
username, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read administrator username: %w", err)
}
username = strings.TrimSpace(username)
if username == "" {
username = admin.Username
} }
fmt.Print(m.newPassword()) fmt.Print(m.newPassword())
newPw, err := readPasswordMasked() newPw, err := readPasswordMasked()
@@ -208,19 +237,9 @@ func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) erro
if newPw != confirmPw { if newPw != confirmPw {
return errPasswordsDiffer return errPasswordsDiffer
} }
if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil { if err := authService.ResetAdminCredentials(ctx, username, newPw); err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
return errCurrentWrong
}
return fmt.Errorf("%w: %v", errMenuAuth, err) return fmt.Errorf("%w: %v", errMenuAuth, err)
} }
// Persist the new plaintext to the env file so the next EnsureAdmin (on
// restart) agrees with the hash we just wrote to the DB. Without this the
// restart reverts the password to whatever the env file still holds.
if err := rewriteEnvPassword(newPw); err != nil {
logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err)
return fmt.Errorf("%w: %v", errMenuEnvWrite, err)
}
fmt.Println(m.passwordChanged()) fmt.Println(m.passwordChanged())
return nil return nil
} }
@@ -237,14 +256,18 @@ func readPasswordMasked() (string, error) {
return string(bytes), nil return string(bytes), nil
} }
// rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the // rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
// systemd EnvironmentFile and keeps the file 0600. The replacement is atomic: // write is atomic and rejects line breaks so one setting cannot inject another.
// the temp file lives in the same directory so os.Rename stays on one func rewriteEnvValue(path, name, value string) error {
// filesystem. if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
func rewriteEnvPassword(newPassword string) error { return errors.New("invalid environment setting")
const key = "VOCAT_ADMIN_PASSWORD=" }
if strings.HasPrefix(name, "VOCAT_ADMIN_") {
return errors.New("administrator credentials cannot be stored in the environment file")
}
key := name + "="
var lines []string var lines []string
if data, err := os.ReadFile(envFilePath); err == nil { if data, err := os.ReadFile(path); err == nil {
lines = strings.Split(string(data), "\n") lines = strings.Split(string(data), "\n")
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
return err return err
@@ -252,28 +275,50 @@ func rewriteEnvPassword(newPassword string) error {
replaced := false replaced := false
for i, line := range lines { for i, line := range lines {
if strings.HasPrefix(line, "VOCAT_ADMIN_USERNAME=") || strings.HasPrefix(line, "VOCAT_ADMIN_PASSWORD=") || strings.HasPrefix(line, "VOCAT_ADMIN_PASSWORD_B64=") {
lines[i] = ""
continue
}
if strings.HasPrefix(line, key) { if strings.HasPrefix(line, key) {
lines[i] = key + newPassword lines[i] = key + value
replaced = true replaced = true
break break
} }
} }
lines = compactNonEmptyLines(lines)
if !replaced { if !replaced {
lines = append(lines, key+newPassword) lines = append(lines, key+value)
} }
content := strings.Join(lines, "\n") content := strings.Join(lines, "\n")
if !strings.HasSuffix(content, "\n") { if !strings.HasSuffix(content, "\n") {
content += "\n" content += "\n"
} }
return writeEnvFileAtomic(path, []byte(content))
}
dir := envFilePath[:strings.LastIndex(envFilePath, "/")] func compactNonEmptyLines(lines []string) []string {
result := lines[:0]
for _, line := range lines {
if line != "" {
result = append(result, line)
}
}
return result
}
func writeEnvFileAtomic(path string, content []byte) error {
dirIndex := strings.LastIndexAny(path, "/\\")
if dirIndex < 0 {
return errors.New("environment file path has no directory")
}
dir := path[:dirIndex]
tmp, err := os.CreateTemp(dir, ".vocat-env-*") tmp, err := os.CreateTemp(dir, ".vocat-env-*")
if err != nil { if err != nil {
return err return err
} }
tmpName := tmp.Name() tmpName := tmp.Name()
defer os.Remove(tmpName) defer os.Remove(tmpName)
if _, err := tmp.WriteString(content); err != nil { if _, err := tmp.Write(content); err != nil {
_ = tmp.Close() _ = tmp.Close()
return err return err
} }
@@ -284,7 +329,125 @@ func rewriteEnvPassword(newPassword string) error {
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {
return err return err
} }
return os.Rename(tmpName, envFilePath) return os.Rename(tmpName, path)
}
func menuChangeWebPort(reader *bufio.Reader, m *menu) error {
if _, err := exec.LookPath("systemctl"); err != nil {
return errNoSystemctl
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
_, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
fmt.Println(m.currentWebAddress(cfg.Address))
fmt.Println(m.reverseProxyNotice())
fmt.Print(m.newWebPort(currentPortText))
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read Web port: %w", err)
}
portText := strings.TrimSpace(line)
if portText == "" {
fmt.Println(m.webPortCancelled())
return nil
}
newAddress, newPort, err := webAddressWithPort(cfg.Address, portText)
if err != nil {
return errInvalidWebPort
}
currentPort, _ := strconv.Atoi(currentPortText)
if newPort == currentPort {
fmt.Println(m.webPortUnchanged())
return nil
}
listener, err := net.Listen("tcp", newAddress)
if err != nil {
return fmt.Errorf("%w: %v", errWebPortUnavailable, err)
}
_ = listener.Close()
environmentPath := menuEnvFilePath()
original, readErr := os.ReadFile(environmentPath)
originalExisted := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return fmt.Errorf("%w: %v", errMenuPortWrite, readErr)
}
if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil {
return fmt.Errorf("%w: %v", errMenuPortWrite, err)
}
if err := restartVocatService(); err != nil {
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
_ = restartVocatService()
if rollbackErr != nil {
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
}
return fmt.Errorf("%w: %v", errRestartFailed, err)
}
if err := waitForWebListener(newAddress, 5*time.Second); err != nil {
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
_ = restartVocatService()
if rollbackErr != nil {
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
}
return fmt.Errorf("%w: %v", errRestartFailed, err)
}
_ = os.Setenv("VOCAT_ADDR", newAddress)
fmt.Println(m.webPortChanged(newAddress))
return nil
}
func webAddressWithPort(address, portText string) (string, int, error) {
host, _, err := net.SplitHostPort(strings.TrimSpace(address))
if err != nil {
return "", 0, err
}
port, err := strconv.Atoi(strings.TrimSpace(portText))
if err != nil || port < 1 || port > 65535 {
return "", 0, errInvalidWebPort
}
return net.JoinHostPort(host, strconv.Itoa(port)), port, nil
}
func waitForWebListener(address string, timeout time.Duration) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
switch host {
case "", "0.0.0.0":
host = "127.0.0.1"
case "::":
host = "::1"
}
target := net.JoinHostPort(host, port)
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond)
if dialErr == nil {
_ = connection.Close()
return nil
}
lastErr = dialErr
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr)
}
func restoreMenuEnvFile(path string, content []byte, existed bool) error {
if existed {
return writeEnvFileAtomic(path, content)
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
} }
// menuToggleLanguage flips the persisted language preference between "zh" and // menuToggleLanguage flips the persisted language preference between "zh" and
@@ -327,6 +490,14 @@ func menuToggleLanguage(m *menu, logger *slog.Logger) error {
} }
func menuRestart(m *menu) error { func menuRestart(m *menu) error {
if err := restartVocatService(); err != nil {
return err
}
fmt.Println(m.restarted())
return nil
}
func restartVocatService() error {
if _, err := exec.LookPath("systemctl"); err != nil { if _, err := exec.LookPath("systemctl"); err != nil {
return errNoSystemctl return errNoSystemctl
} }
@@ -334,7 +505,9 @@ func menuRestart(m *menu) error {
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out))) return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
} }
fmt.Println(m.restarted()) if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil {
return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out)))
}
return nil return nil
} }
@@ -350,6 +523,7 @@ func menuUpdate(m *menu, logger *slog.Logger) error {
} }
fmt.Println(m.updateChecking()) fmt.Println(m.updateChecking())
if err := update.Run(logger, []string{"--repo", repo}); err != nil { if err := update.Run(logger, []string{"--repo", repo}); err != nil {
logger.Error("menu update failed", "error", err)
return fmt.Errorf("%w: %v", errUpdateFailed, err) return fmt.Errorf("%w: %v", errUpdateFailed, err)
} }
return nil return nil
@@ -378,6 +552,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
_ = os.Remove(systemdUnitPath) _ = os.Remove(systemdUnitPath)
_ = os.RemoveAll("/opt/vocat") _ = os.RemoveAll("/opt/vocat")
_ = os.Remove(envFilePath) _ = os.Remove(envFilePath)
_ = os.Remove(legacyEnvFilePath)
_ = os.Remove("/etc/vocat") // succeeds only when empty _ = os.Remove("/etc/vocat") // succeeds only when empty
runIgnore("systemctl", "daemon-reload") runIgnore("systemctl", "daemon-reload")
runIgnore("userdel", "vocat") runIgnore("userdel", "vocat")
@@ -388,15 +563,16 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
// menu-local sentinel errors so callers can map them to localized messages. // menu-local sentinel errors so callers can map them to localized messages.
var ( var (
errCurrentWrong = errors.New("menu: current password is incorrect") errPasswordsDiffer = errors.New("menu: passwords do not match")
errPasswordsDiffer = errors.New("menu: passwords do not match") errNoSystemctl = errors.New("menu: systemctl not found")
errNoSystemctl = errors.New("menu: systemctl not found") errRestartFailed = errors.New("menu: restart failed")
errRestartFailed = errors.New("menu: restart failed") errUpdateFailed = errors.New("menu: update failed")
errUpdateFailed = errors.New("menu: update failed") errMenuConfig = errors.New("menu: load configuration")
errMenuConfig = errors.New("menu: load configuration") errMenuStore = errors.New("menu: open database")
errMenuStore = errors.New("menu: open database") errMenuAuth = errors.New("menu: auth service")
errMenuAuth = errors.New("menu: auth service") errMenuPortWrite = errors.New("menu: write Web port")
errMenuEnvWrite = errors.New("menu: write env file") errInvalidWebPort = errors.New("menu: invalid Web port")
errWebPortUnavailable = errors.New("menu: Web port unavailable")
) )
// ---- i18n ---- // ---- i18n ----
@@ -409,18 +585,28 @@ func newMenu(lang string) *menu { return &menu{lang: lang} }
func (m *menu) msg(key string) string { func (m *menu) msg(key string) string {
const zh, en = 0, 1 const zh, en = 0, 1
table := map[string][2]string{ table := map[string][2]string{
"title": {"vocat 管理菜单", "vocat management menu"}, "title": {"vocat 管理菜单", "vocat management menu"},
"opt_lang": {"1) 切换中英文", "1) Toggle language"}, "opt_lang": {"1) 切换中英文", "1) Toggle language"},
"opt_change": {"2) 修改账号密码", "2) Change admin password"}, "opt_change": {"2) 修改账号密码", "2) Change admin credentials"},
"opt_restart": {"3) 重启软件", "3) Restart software"}, "opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
"opt_update": {"4) 更新软件", "4) Update software"}, "opt_restart": {"4) 重启软件", "4) Restart software"},
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"}, "opt_update": {"5) 更新软件", "5) Update software"},
"prompt": {"请选择: ", "Select: "}, "opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."}, "prompt": {"请选择: ", "Select: "},
"cur_pw": {"当前密码: ", "Current password: "}, "invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "}, "new_username": {"新用户名(直接回车保留 %s: ", "New username (Enter to keep %s): "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "}, "new_pw": {"新密码: ", "New password: "},
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."}, "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"},
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
"web_port_unchanged": {"端口未改变。", "Web port is unchanged."},
"web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."},
"reverse_proxy_notice": {
"如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。",
"If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.",
},
"lang_switched": { "lang_switched": {
"语言已切换。Web 界面下次刷新后同步。", "语言已切换。Web 界面下次刷新后同步。",
"Language switched. The web UI syncs on next refresh.", "Language switched. The web UI syncs on next refresh.",
@@ -431,9 +617,9 @@ func (m *menu) msg(key string) string {
"警告: 将删除程序、数据与配置,且不可恢复!", "警告: 将删除程序、数据与配置,且不可恢复!",
"WARNING: removes the program, data and config. Irreversible!", "WARNING: removes the program, data and config. Irreversible!",
}, },
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "}, "uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
"uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."}, "uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."}, "uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
} }
entry, ok := table[key] entry, ok := table[key]
if !ok { if !ok {
@@ -445,25 +631,38 @@ func (m *menu) msg(key string) string {
return entry[zh] return entry[zh]
} }
func (m *menu) title() string { return m.msg("title") } func (m *menu) title() string { return m.msg("title") }
func (m *menu) prompt() string { return m.msg("prompt") } func (m *menu) prompt() string { return m.msg("prompt") }
func (m *menu) invalid() string { return m.msg("invalid") } func (m *menu) invalid() string { return m.msg("invalid") }
func (m *menu) currentPassword() string { return m.msg("cur_pw") } func (m *menu) newUsername(current string) string {
func (m *menu) newPassword() string { return m.msg("new_pw") } return fmt.Sprintf(m.msg("new_username"), current)
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") } }
func (m *menu) passwordChanged() string { return m.msg("pw_changed") } func (m *menu) newPassword() string { return m.msg("new_pw") }
func (m *menu) languageSwitched() string { return m.msg("lang_switched") } func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
func (m *menu) updateChecking() string { return m.msg("upd_checking") } func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
func (m *menu) restarted() string { return m.msg("restarted") } func (m *menu) currentWebAddress(address string) string {
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") } return fmt.Sprintf(m.msg("current_web_address"), address)
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") } }
func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") }
func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") }
func (m *menu) webPortChanged(address string) string {
return fmt.Sprintf(m.msg("web_port_changed"), address)
}
func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") }
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
func (m *menu) restarted() string { return m.msg("restarted") }
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") } func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
func (m *menu) uninstalled() string { return m.msg("uninstalled") } func (m *menu) uninstalled() string { return m.msg("uninstalled") }
func (m *menu) options() []string { func (m *menu) options() []string {
return []string{ return []string{
m.msg("opt_lang"), m.msg("opt_lang"),
m.msg("opt_change"), m.msg("opt_change"),
m.msg("opt_port"),
m.msg("opt_restart"), m.msg("opt_restart"),
m.msg("opt_update"), m.msg("opt_update"),
m.msg("opt_uninstall"), m.msg("opt_uninstall"),
@@ -472,11 +671,6 @@ func (m *menu) options() []string {
func (m *menu) errorPrefix(err error) string { func (m *menu) errorPrefix(err error) string {
switch { switch {
case errors.Is(err, errCurrentWrong):
if m.lang == "en" {
return "Current password is incorrect."
}
return "当前密码不正确。"
case errors.Is(err, errPasswordsDiffer): case errors.Is(err, errPasswordsDiffer):
if m.lang == "en" { if m.lang == "en" {
return "Passwords do not match." return "Passwords do not match."
@@ -493,10 +687,11 @@ func (m *menu) errorPrefix(err error) string {
} }
return "重启失败。" return "重启失败。"
case errors.Is(err, errUpdateFailed): case errors.Is(err, errUpdateFailed):
detail := strings.TrimPrefix(err.Error(), errUpdateFailed.Error()+": ")
if m.lang == "en" { if m.lang == "en" {
return "Update failed." return "Update failed: " + detail
} }
return "更新失败。" return "更新失败: " + detail
case errors.Is(err, errMenuConfig): case errors.Is(err, errMenuConfig):
if m.lang == "en" { if m.lang == "en" {
return "Failed to load configuration." return "Failed to load configuration."
@@ -512,11 +707,21 @@ func (m *menu) errorPrefix(err error) string {
return "Auth service error." return "Auth service error."
} }
return "认证服务错误。" return "认证服务错误。"
case errors.Is(err, errMenuEnvWrite): case errors.Is(err, errInvalidWebPort):
if m.lang == "en" { if m.lang == "en" {
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "." return "Invalid port. Enter a number from 1 to 65535."
} }
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。" return "端口无效,请输入 1 到 65535。"
case errors.Is(err, errWebPortUnavailable):
if m.lang == "en" {
return "The new Web port is unavailable or already in use."
}
return "新的 Web 端口不可用或已被占用。"
case errors.Is(err, errMenuPortWrite):
if m.lang == "en" {
return "Failed to save the Web listening port to " + menuEnvFilePath() + "."
}
return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。"
default: default:
if m.lang == "en" { if m.lang == "en" {
return "Error: " + err.Error() return "Error: " + err.Error()
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWebAddressWithPort(t *testing.T) {
tests := []struct {
name string
address string
port string
want string
wantErr bool
}{
{name: "IPv4", address: "0.0.0.0:7575", port: "8080", want: "0.0.0.0:8080"},
{name: "IPv6", address: "[::]:7575", port: "8443", want: "[::]:8443"},
{name: "minimum", address: "127.0.0.1:7575", port: "1", want: "127.0.0.1:1"},
{name: "maximum", address: "127.0.0.1:7575", port: "65535", want: "127.0.0.1:65535"},
{name: "zero", address: "0.0.0.0:7575", port: "0", wantErr: true},
{name: "too large", address: "0.0.0.0:7575", port: "65536", wantErr: true},
{name: "not numeric", address: "0.0.0.0:7575", port: "http", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, _, err := webAddressWithPort(test.address, test.port)
if test.wantErr {
if !errors.Is(err, errInvalidWebPort) {
t.Fatalf("error = %v, want errInvalidWebPort", err)
}
return
}
if err != nil || got != test.want {
t.Fatalf("webAddressWithPort() = %q, %v; want %q", got, err, test.want)
}
})
}
}
func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "env")
if err := os.WriteFile(path, []byte("VOCAT_ADMIN_PASSWORD=secret\nVOCAT_ADDR=0.0.0.0:7575\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:8080"); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(content)
if strings.Contains(got, "VOCAT_ADMIN_PASSWORD") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
t.Fatalf("rewritten env = %q", got)
}
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
t.Fatal("environment line injection was accepted")
}
if err := rewriteEnvValue(path, "VOCAT_ADMIN_PASSWORD", "changed-password"); err == nil {
t.Fatal("administrator credential was accepted for the environment file")
}
}
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
options := strings.Join(newMenu(lang).options(), "\n")
if !strings.Contains(options, "3)") || !strings.Contains(strings.ToLower(options), "web") {
t.Fatalf("%s menu options do not contain Web port entry: %q", lang, options)
}
}
}
func TestMenuCredentialResetPromptsDoNotRequestCurrentPassword(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
menu := newMenu(lang)
prompts := strings.Join([]string{
menu.newUsername("admin"),
menu.newPassword(),
menu.confirmPassword(),
}, "\n")
if strings.Contains(strings.ToLower(prompts), "current password") || strings.Contains(prompts, "当前密码") {
t.Fatalf("%s credential reset still requests the current password: %q", lang, prompts)
}
if !strings.Contains(prompts, "admin") {
t.Fatalf("%s username prompt does not show the current username: %q", lang, prompts)
}
}
}
+76
View File
@@ -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)
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"context"
"log/slog"
"math"
"strings"
"time"
"vocat/internal/store"
)
const cellularTrafficSampleInterval = 30 * time.Second
type interfaceTrafficSample struct {
interfaceName string
rxBytes uint64
txBytes uint64
}
func collectCellularTraffic(ctx context.Context, logger *slog.Logger, database *store.Store) {
previous := make(map[string]interfaceTrafficSample)
var lastPrune time.Time
collect := func() {
now := time.Now()
if lastPrune.IsZero() || now.Sub(lastPrune) >= 24*time.Hour {
lastPrune = now
if _, err := database.DeleteTrafficBefore(ctx, now.Add(-35*24*time.Hour)); err != nil && ctx.Err() == nil {
logger.Warn("prune old cellular traffic", "error", err)
}
}
configs, err := database.ListDevices(ctx)
if err != nil {
if ctx.Err() == nil {
logger.Warn("list devices for cellular traffic collection", "error", err)
}
return
}
active := make(map[string]struct{}, len(configs))
for _, config := range configs {
interfaceName := strings.TrimSpace(config.Interface)
if !config.NetworkEnabled || interfaceName == "" {
delete(previous, config.ID)
continue
}
active[config.ID] = struct{}{}
rxBytes, txBytes, err := readInterfaceTrafficCounters(interfaceName)
if err != nil {
// Interfaces can briefly disappear while QMI reconnects. The next
// successful read establishes a fresh baseline, so no reconnect
// traffic is accidentally counted twice.
delete(previous, config.ID)
continue
}
rxDelta, txDelta, ok := trafficCounterDelta(previous[config.ID], interfaceName, rxBytes, txBytes)
previous[config.ID] = interfaceTrafficSample{
interfaceName: interfaceName,
rxBytes: rxBytes,
txBytes: txBytes,
}
if !ok || (rxDelta == 0 && txDelta == 0) {
continue
}
for bucket, periodStart := range trafficBucketPeriods(time.Now()) {
if err := database.AddTrafficBucket(ctx, store.TrafficBucket{
DeviceID: config.ID,
Bucket: bucket,
PeriodStart: periodStart,
RXBytes: rxDelta,
TXBytes: txDelta,
}); err != nil && ctx.Err() == nil {
logger.Warn("record cellular traffic", "device", config.ID, "bucket", bucket, "error", err)
}
}
}
for deviceID := range previous {
if _, ok := active[deviceID]; !ok {
delete(previous, deviceID)
}
}
}
collect()
ticker := time.NewTicker(cellularTrafficSampleInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
collect()
}
}
}
func trafficCounterDelta(previous interfaceTrafficSample, interfaceName string, rxBytes, txBytes uint64) (int64, int64, bool) {
if previous.interfaceName == "" || previous.interfaceName != interfaceName || rxBytes < previous.rxBytes || txBytes < previous.txBytes {
return 0, 0, false
}
rxDelta := rxBytes - previous.rxBytes
txDelta := txBytes - previous.txBytes
if rxDelta > math.MaxInt64 || txDelta > math.MaxInt64 {
return 0, 0, false
}
return int64(rxDelta), int64(txDelta), true
}
func trafficBucketPeriods(now time.Time) map[string]time.Time {
local := now.In(time.Local)
year, month, day := local.Date()
dayStart := time.Date(year, month, day, 0, 0, 0, 0, time.Local).UTC()
return map[string]time.Time{
"hour": now.UTC().Truncate(time.Minute),
"day": now.UTC().Truncate(time.Hour),
"week": dayStart,
"month": dayStart,
}
}
+39
View File
@@ -0,0 +1,39 @@
//go:build linux
package main
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
func readInterfaceTrafficCounters(interfaceName string) (uint64, uint64, error) {
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return 0, 0, err
}
read := func(counter string) (uint64, error) {
value, err := os.ReadFile(filepath.Join("/sys/class/net", iface.Name, "statistics", counter))
if err != nil {
return 0, err
}
parsed, err := strconv.ParseUint(strings.TrimSpace(string(value)), 10, 64)
if err != nil {
return 0, fmt.Errorf("parse %s %s counter: %w", iface.Name, counter, err)
}
return parsed, nil
}
rxBytes, err := read("rx_bytes")
if err != nil {
return 0, 0, err
}
txBytes, err := read("tx_bytes")
if err != nil {
return 0, 0, err
}
return rxBytes, txBytes, nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package main
import "errors"
func readInterfaceTrafficCounters(string) (uint64, uint64, error) {
return 0, 0, errors.New("interface traffic counters are only available on Linux")
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"testing"
"time"
)
func TestTrafficCounterDelta(t *testing.T) {
previous := interfaceTrafficSample{interfaceName: "wwan0", rxBytes: 100, txBytes: 50}
rx, tx, ok := trafficCounterDelta(previous, "wwan0", 175, 90)
if !ok || rx != 75 || tx != 40 {
t.Fatalf("delta = (%d, %d, %v), want (75, 40, true)", rx, tx, ok)
}
if _, _, ok := trafficCounterDelta(previous, "wwan1", 175, 90); ok {
t.Fatal("interface change must establish a new baseline")
}
if _, _, ok := trafficCounterDelta(previous, "wwan0", 90, 40); ok {
t.Fatal("counter reset must establish a new baseline")
}
}
func TestTrafficBucketPeriods(t *testing.T) {
now := time.Date(2026, 8, 10, 12, 34, 56, 0, time.Local)
periods := trafficBucketPeriods(now)
if got := periods["hour"]; !got.Equal(now.UTC().Truncate(time.Minute)) {
t.Fatalf("hour period = %s", got)
}
if got := periods["day"]; !got.Equal(now.UTC().Truncate(time.Hour)) {
t.Fatalf("day period = %s", got)
}
localDay := periods["week"].In(time.Local)
if localDay.Hour() != 0 || localDay.Minute() != 0 || localDay.Day() != 10 {
t.Fatalf("week period = %s, want local day start", periods["week"])
}
if !periods["month"].Equal(periods["week"]) {
t.Fatal("week and month should share daily periods")
}
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"errors"
"testing"
"vocat/internal/device"
)
type startupFlightSetter struct {
errors []error
calls int
id string
}
func (setter *startupFlightSetter) SetFlight(
_ context.Context,
id string,
enabled bool,
) (device.FlightResult, error) {
setter.calls++
setter.id = id
if !enabled {
return device.FlightResult{}, errors.New("expected flight mode to be enabled")
}
if setter.calls <= len(setter.errors) {
return device.FlightResult{}, setter.errors[setter.calls-1]
}
return device.FlightResult{CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
}
func TestProtectVoWiFiStartupRadioRetriesTransientFailure(t *testing.T) {
transient := errors.New("modem is reopening")
setter := &startupFlightSetter{errors: []error{transient, transient}}
if err := protectVoWiFiStartupRadioWithRetry(
context.Background(), setter, "quectel-1", 3, 0,
); err != nil {
t.Fatalf("protect startup radio: %v", err)
}
if setter.calls != 3 || setter.id != "quectel-1" {
t.Fatalf("SetFlight calls = %d, id = %q", setter.calls, setter.id)
}
}
func TestProtectVoWiFiStartupRadioReturnsLastFailure(t *testing.T) {
first := errors.New("first")
last := errors.New("last")
setter := &startupFlightSetter{errors: []error{first, last}}
err := protectVoWiFiStartupRadioWithRetry(
context.Background(), setter, "quectel-1", 2, 0,
)
if !errors.Is(err, last) || setter.calls != 2 {
t.Fatalf("protect startup radio = %v after %d calls", err, setter.calls)
}
}
+1 -2
View File
@@ -13,7 +13,6 @@ Restart=on-failure
RestartSec=3s RestartSec=3s
TimeoutStartSec=30s TimeoutStartSec=30s
TimeoutStopSec=20s TimeoutStopSec=20s
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
EnvironmentFile=/etc/vocat/vocat.env EnvironmentFile=/etc/vocat/vocat.env
@@ -29,7 +28,7 @@ ProtectKernelModules=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectControlGroups=true ProtectControlGroups=true
ReadWritePaths=/opt/vocat/data ReadWritePaths=/opt/vocat/data
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET
RestrictRealtime=true RestrictRealtime=true
LockPersonality=true LockPersonality=true
MemoryDenyWriteExecute=true MemoryDenyWriteExecute=true
+63
View File
@@ -0,0 +1,63 @@
# VoCat Docker Compose deployment.
#
# First-time setup (password is read from stdin and stored only in SQLite):
# docker compose pull
# read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
# printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker compose run --rm -T \
# --entrypoint /opt/vocat/bin/vocat vocat bootstrap-admin
# unset VOCAT_BOOTSTRAP_PASSWORD
# docker compose up -d
#
# Build locally from this repo instead of using the GHCR image:
# docker compose up -d --build
#
# In-container binary self-update is intentionally disabled (VOCAT_CONTAINER=docker
# makes the server return 409 on the apply endpoint). Update by pulling a new
# image and recreating the container:
# docker compose pull && docker compose up -d
services:
vocat:
# Use the prebuilt multi-arch image from GHCR. Override with
# --build to compile from the local Dockerfile instead.
image: ghcr.io/mengmengcode/vocat:latest
pull_policy: missing
build:
context: .
dockerfile: Dockerfile
container_name: vocat
restart: unless-stopped
# Host network mode: the export-proxy plugin uses SO_BINDTODEVICE to pin
# outbound proxy traffic to the modem interface (wwan0) so roaming data
# egresses only the module — never the host's default route. That syscall
# needs the host network namespace visible inside the container, which
# network_mode: host provides directly. Port publishing is therefore
# meaningless (the container shares the host stack and vocat binds
# 0.0.0.0:7575 itself); proxy ports opened by the plugin are likewise
# reachable on the host IP without explicit mapping.
network_mode: host
# Modem/QMI/USB-reader hot-plug uses dynamic character devices. Privileged
# mode mirrors the documented hardware-access docker run command and also
# supplies the raw-socket/netlink permissions needed by VoWiFi/IPsec.
privileged: true
user: "0:0"
environment:
# Marks the process as containerized: the web UI then advertises
# "pull new image" instead of attempting an in-place binary update.
VOCAT_CONTAINER: docker
# VOCAT_ADDR / VOCAT_DATABASE_PATH are set in the Dockerfile. Admin
# credentials are stored only in SQLite and are not process environment.
volumes:
# SQLite database + persistent state.
- vocat-data:/opt/vocat/data
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
# devices added after the container starts.
- /dev:/dev
- /sys:/sys:ro
volumes:
vocat-data:
+341
View File
@@ -0,0 +1,341 @@
<p align="center">
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
</p>
<h1 align="center">VoCat</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
</p>
<p align="center">
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p>
[English](../README.md) | **العربية** | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
Vocat هي لوحة تحكم ويب مفتوحة المصدر ومجموعة أدوات هندسية لمودمات Quectel الخلوية من فئة EC20/EC25. تجمع في خدمة واحدة مكتفية ذاتيًا بين اكتشاف المودم، وحالة الراديو المباشرة، وطرفيات AT وUSSD، والرسائل القصيرة SMS، وWiFi Calling، وإدارة eSIM، واختيار الشبكة، والتوجيه عبر البروكسي، والإشعارات، وسجلات التدقيق، وأتمتة الإصدارات.
الواجهة الخلفية مكتوبة بلغة Go، والواجهة مبنية باستخدام React وTypeScript، وتُضمَّن واجهة الإنتاج الأمامية داخل الملف الثنائي لـ Go. يحتوي ملف تنفيذي واحد على تطبيق الويب ويستخدم SQLite للحالة الدائمة.
<p align="center">
<img src="../img/image.png">
<img src="../img/image-1.png">
</p>
## الميزات
| المجال | ما يوفره Vocat |
| --- | --- |
| إدارة الأجهزة | اكتشاف تلقائي عبر المنفذ التسلسلي/USB، دعم عدة مودمات، أسماء أجهزة مألوفة، تحديثات مباشرة للنظرة العامة، إعادة تشغيل الوحدة، وضع الطيران، وضوابط وضع شبكة USB. |
| الراديو والشبكة | حالة التسجيل، المشغّل، مقاييس الإشارة، RSRP/RSRQ/SINR، وضع الشبكة، النطاق، القناة، فحص المشغّلين، والاختيار التلقائي أو اليدوي للشبكة. |
| AT وUSSD | طرفية AT تفاعلية، سجل الأوامر، استجابات المودم الخام، تدفقات بدء/متابعة/إلغاء USSD، والإبلاغ الواضح عن أخطاء المودم. |
| الرسائل القصيرة | إرسال مباشر للرسائل الخلوية ورسائل IMS، المزامنة الواردة، التعامل مع الرسائل متعددة الأجزاء، تقارير التسليم، سجل المحادثات، حالة عدم القراءة، الطوابع الزمنية، وحالة التسليم لكل رسالة. |
| WiFi Calling | إنشاء نفق IKEv2/ePDG، مصادقة EAP-AKA، تسجيل IMS، رسائل IMS القصيرة، ضوابط إعادة الاتصال، تشخيصات الحالة، والتوجيه لكل جهاز. |
| eSIM وeUICC | اكتشاف eUICC، ومعلومات EID والإنتاج، والبيانات الوصفية للشهادات، وجرد متعدد لـ eUICC، وقائمة الملفات الشخصية المثبتة، وعمليات التمكين/التعطيل/التبديل، وعمليات التنزيل وإعادة التسمية والحذف عندما تدعمها البطاقة. |
| سياسة البطاقة | سلوك WiFi Calling ووضع الطيران بناءً على ICCID مع تطبيق فوري للسياسة. |
| التوجيه عبر البروكسي | توجيه SOCKS صاعد، ربط الأجهزة، قواعد الدول، فحوصات الوصول عبر TCP، وفحوصات UDP Associate لمسارات بيانات WiFi Calling. |
| الإشعارات | إعادة توجيه الرسائل القصيرة الواردة الجديدة عبر Telegram وBark والبريد الإلكتروني وPushplus وwebhooks الموقّعة. يتم تسليم كل رسالة كإشعار منفصل. |
| بوت Telegram | حالة الجهاز، قائمة الملفات الشخصية المثبتة وتبديلها، ضوابط WiFi Calling، وإرسال الرسائل القصيرة. تتطلب الإجراءات الحساسة تأكيد المسؤول. |
| العمليات | المصادقة، الحماية من CSRF، سياسات الوصول، أحداث التدقيق، السجلات المباشرة، الاحتفاظ بالسجلات، فحوصات الصحة، تخطيط متجاوب، الوضع الداكن، وواجهة مستخدم بالإنجليزية/الصينية. |
| التوزيع | ملفات Linux الثنائية الثابتة، سكربت تثبيت systemd، تحديث ذاتي مع التحقق من SHA-256، صورة Docker، النشر إلى GHCR، وبنى إصدارات GitHub Actions. |
## الأجهزة المدعومة
يستهدف Vocat وحدات Quectel المبنية على Qualcomm والتي توفر واجهات AT وQMI والمنفذ التسلسلي وشبكة USB المتوافقة، بما في ذلك:
- Quectel EC20
- Quectel EC25
- عائلة Quectel EG25
- وحدات EG600 المتوافقة وذات الصلة
تعتمد الميزات المتاحة على برنامج الوحدة الثابت (firmware)، وتكوين USB، وقدرات SIM/eSIM، وتعريفات المضيف، والشبكة اللاسلكية، وإعدادات المشغّل.
## التثبيت
### تثبيت Linux بنقرة واحدة
بصفتك root (بما في ذلك OpenWrt/Kwrt، حيث يكون `sudo` غير موجود عادةً):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
من مستخدم عادي على توزيعة تحتوي على sudo:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
تحقق من متطلبات VoWiFi/XFRM على المضيف دون تثبيت VoCat:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
تثبيت إصدار محدد:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
sudo bash install.sh 0.0.2
```
يتطلب VoWiFi IMS وجود Linux XFRM/IPsec. على OpenWrt/Kwrt يحاول المثبّت
تثبيت الحزم المطابقة `ip-full` و`kmod-ipsec` و`kmod-ipsec4/6`
و`kmod-crypto-authenc` وAES-CBC وSHA1 من مستودع البرنامج الثابت نفسه.
إذا لم تكن وحدات النواة المطابقة متاحة، فاستخدم برنامجًا ثابتًا يتضمنها؛
ولا تفرض أبدًا تثبيت kmods مبنية لنواة مختلفة.
المثبّت:
- يكتشف `amd64` أو `386` أو `arm64` أو `aarch64` أو `armv7`؛
- ينزّل الملف الثنائي المطابق من GitHub Release؛
- يتحقق منه مقابل `SHA256SUMS`؛
- يثبّت Vocat في `/opt/vocat`؛
- ينشئ خدمة systemd محصّنة بوصول الأجهزة والشبكة الذي يتطلبه Vocat؛
- يخزّن إعدادات وقت التشغيل في `/etc/vocat/env`؛
- يولّد كلمة مرور مسؤول أولية عشوائية عند التثبيت الأول.
بعد التثبيت، افتح:
```text
http://<عنوان-الخادم>:7575
```
### التثبيت اليدوي للملف الثنائي
نزّل الملف الثنائي المطابق و`SHA256SUMS` من GitHub Releases:
| المنصة | ملف الإصدار |
| --- | --- |
| Linux x86-64 | `vocat-linux-amd64` |
| Linux x86 32-بت | `vocat-linux-386` |
| Linux ARM64 | `vocat-linux-arm64` |
| Linux AArch64 | `vocat-linux-aarch64` |
| Linux ARMv7 | `vocat-linux-armv7` |
تحقق منه وثبّته:
```bash
sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
/opt/vocat/bin/vocat serve
```
يشغّل هذا الأمر اليدوي Vocat في المقدمة. استخدم `vocat serve` حتى
يبدأ العملية الخادم مباشرةً؛ إن تشغيل `vocat` دون وسائط بصفتك root
على TTY يفتح بدلاً من ذلك قائمة الإدارة التفاعلية. استخدم المثبّت بنقرة
واحدة عند الحاجة إلى خدمة systemd مُدارة وإعادة تشغيل تلقائية.
### Docker
لمضيف Linux الذي يجب أن يكتشف كل مودم Quectel مدعوم متصل ويواصل
رؤية أحداث التوصيل الساخن لـ USB، شغّل Vocat في وضع الوصول إلى الأجهزة:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \
--name vocat \
--restart unless-stopped \
--network host \
--privileged \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
-v /dev:/dev \
-v /sys:/sys:ro \
ghcr.io/mengmengcode/vocat:latest
```
افتح `http://<عنوان-الخادم>:7575` بعد بدء الحاوية. شبكة المضيف
مطلوبة حتى تبقى واجهات شبكة QMI مرئية لـ Vocat، بينما الوصول المميّز إلى الأجهزة
مطلوب للمنافذ التسلسلية، وعقد تحكم QMI، وواجهات TUN، وإعدادات الشبكة، والأجهزة
المضافة بعد بدء الحاوية. يجعل تركيب `/dev` العقد الجديدة `ttyUSB*` و`ttyACM*` و`cdc-wdm*`
مرئية دون إعادة إنشاء الحاوية.
يمنح هذا الوضع عمدًا Vocat وصولاً واسعًا إلى أجهزة المضيف ومكدس الشبكة.
استخدمه فقط على مضيف Linux موثوق. يتعرف الاكتشاف التلقائي حاليًا على مودمات
Quectel USB المدعومة (معرّف الشركة المصنعة USB `2c7c`)، وليس على ماركات مودم عشوائية.
إن تركيب العقد الفردية فقط باستخدام `--device`، مثل `/dev/ttyUSB2` و`/dev/cdc-wdm0`،
يحصر الحاوية في تلك العقد الثابتة ولا يوفر اكتشافًا كاملاً متعدد الأجهزة أو بالتوصيل الساخن.
تُنشر صورة GHCR لـ `linux/amd64` و`linux/arm64`.
## الإعدادات
يقرأ Vocat ملف إعدادات JSON اختياريًا من `VOCAT_CONFIG`، ثم يطبق متغيرات البيئة `VOCAT_*`. متغيرات البيئة لها الأولوية.
| متغير البيئة | الافتراضي | الوصف |
| --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | عنوان الاستماع HTTP. |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | مسار قاعدة بيانات SQLite. |
| `VOCAT_SESSION_TTL` | `24h` | مدة صلاحية جلسة المصادقة. |
| `VOCAT_SECURE_COOKIES` | `false` | يضع علامة آمنة على ملفات تعريف ارتباط الجلسة عند استخدام HTTPS. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | مهلة الإيقاف السلس. |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | الحد الأقصى لحجم جسم طلب API. |
| `VOCAT_REPO` | `MengMengCode/VoCat` | مستودع GitHub الموثوق الذي يستخدمه المحدّث الذاتي، بصيغة `owner/name`. |
| `GITHUB_TOKEN` | فارغ | رمز GitHub اختياري للمستودعات الخاصة أو حدود API أعلى. |
لا تخزّن رموز Telegram، أو كلمات مرور SMTP، أو أسرار webhook، أو بيانات اعتماد SIM، أو بيانات خاصة أخرى في المستودع. قم بإعدادها عبر إعدادات التطبيق أو ملفات البيئة المحمية.
## بوت Telegram
عند تفعيل إشعارات Telegram وإعداد كلٍّ من Chat ID وAdmin ID، يدعم البوت:
```text
/status [الجهاز]
/esim <الجهاز>
/switch <الجهاز> <iccid>
/wfc <الجهاز> <status|on|off|reconnect>
/sms <الجهاز> <الرقم> <الرسالة>
```
تستخدم عمليتا تبديل الملفات الشخصية وإرسال الرسائل القصيرة أزرار تأكيد لمرة واحدة. لا يعرض البوت أوامر تنزيل أو حذف أو إعادة تسمية eSIM.
## التحديث
تحقق من وجود GitHub Release أحدث:
```bash
vocat update --check --repo MengMengCode/VoCat
```
ثبّت أحدث إصدار:
```bash
sudo vocat update --repo MengMengCode/VoCat
```
ينزّل المحدّث الملف الثنائي المطابق لبنية Linux الحالية، ويتحقق منه باستخدام `SHA256SUMS` المنشور، ويستبدل الملف التنفيذي بشكل ذري، ويعيد تشغيل خدمة systemd `vocat` عند توفرها.
لتثبيتات Docker:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
```
أعد إنشاء الحاوية بعد سحب الصورة الجديدة.
## التطوير
المتطلبات:
- Go 1.25 أو أحدث
- Node.js 20 أو أحدث
- npm
تشغيل خادم تطوير الواجهة الأمامية:
```bash
cd web
npm install
npm run dev
```
بناء الواجهة الأمامية المضمّنة وتشغيل الواجهة الخلفية:
```bash
cd web
npm run build
cd ..
go run ./cmd/vocat
```
تشغيل جميع الاختبارات:
```bash
go test ./...
```
بناء ملف ثنائي للإنتاج:
```bash
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
```
## أتمتة الإصدارات
يؤدي دفع وسم الإصدار إلى بدء سير عملَي GitHub Actions:
- `release-binaries` يبني وينشر ملفات `amd64` و`386` و`arm64` و`aarch64` و`armv7` الثنائية مع `SHA256SUMS`.
- `docker` يبني وينشر صورة متعددة البنى إلى GitHub Container Registry.
```bash
git tag v0.2.0
git push origin v0.2.0
```
## بنية المشروع
```text
cmd/vocat/ نقطة دخول التطبيق وCLI
internal/device/ اكتشاف المودم والتحكم في الأجهزة
internal/modem/ جلسة AT ومعالجة الاستجابات
internal/server/ واجهة HTTP API والإشعارات وخادم الويب المضمّن
internal/store/ التخزين الدائم SQLite
internal/update/ المحدّث الذاتي لـ GitHub Release
internal/vowifi/ بيئة تشغيل IKE وEAP-AKA وIMS وWiFi Calling
scripts/install.sh مثبّت ومحدّث Linux
web/src/ الواجهة الأمامية React وTypeScript
.github/workflows/ أتمتة إصدارات الملفات الثنائية وDocker
```
## الاستخدام المسؤول
يمكن أن تؤثر عمليات المودم الخلوي وeSIM في خدمة المشترك، والملفات الشخصية المخزنة، وتسجيل الشبكة، وحالة الأجهزة. احتفظ بنسخ احتياطية، وراجع الإجراءات المدمّرة بعناية، واستخدم البرنامج فقط في بيئات قانونية يُسمح لك فيها بتشغيل الأجهزة وموارد الشبكة المتصلة.
لا يتجاوز Vocat مصادقة المشغّل، أو سياسة الشبكة، أو أمان الأجهزة، أو متطلبات الثقة لـ eSIM. إن دعم عملية ما يعني أن Vocat يمكن أن يطلبها من المودم أو eUICC؛ وقد يرفضها الجهاز أو الملف الشخصي أو الشبكة أو المشغّل مع ذلك.
## المساهمة
نرحّب بالمسائل (issues) وطلبات السحب (pull requests). حافظ على التغييرات مركّزة، وأضِف الاختبارات حيثما أمكن، وتجنّب إيداع بيانات الاعتماد أو بيانات المشتركين، ووثّق بوضوح السلوك الخاص بالأجهزة.
قبل إرسال تغيير:
```bash
go test ./...
cd web && npm run build
```
## شكر وتقدير
- [Nodeseek.com](https://www.nodeseek.com) — مجتمع مكرّس للخوادم
- [Linux.do](https://linux.do) — مجتمع تقني ملهم
- [iniwex5](https://github.com/iniwex5) — إرشادات الأسلوب والوظائف
## ادعُني إلى فنجان قهوة
| الشبكة | العنوان |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## الرخصة
انظر [LICENSE](../LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+343
View File
@@ -0,0 +1,343 @@
<p align="center">
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
</p>
<h1 align="center">VoCat</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
</p>
<p align="center">
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p>
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | **Español** | [日本語](README.ja.md)
Vocat es un panel de control web de código abierto y un conjunto de herramientas de ingeniería para módems celulares Quectel de clase EC20/EC25. Combina, en un único servicio autocontenido, el descubrimiento de módems, el estado de radio en vivo, terminales AT y USSD, SMS, WiFi Calling, gestión de eSIM, selección de red, enrutamiento por proxy, notificaciones, registros de auditoría y automatización de versiones.
El backend está escrito en Go, la interfaz está construida con React y TypeScript, y el frontend de producción está incrustado en el binario de Go. Un único ejecutable contiene la aplicación web y utiliza SQLite para el estado persistente.
<p align="center">
<img src="../img/image.png">
<img src="../img/image-1.png">
</p>
## Funcionalidades
| Área | Lo que proporciona Vocat |
| --- | --- |
| Gestión de dispositivos | Descubrimiento serie/USB automático, soporte para múltiples módems, nombres de dispositivo amigables, actualizaciones en vivo de la vista general, reinicio del módulo, modo avión y controles del modo de red USB. |
| Radio y red | Estado de registro, operador, métricas de señal, RSRP/RSRQ/SINR, modo de red, banda, canal, búsqueda de operadores y selección de red automática o manual. |
| AT y USSD | Terminal AT interactivo, historial de comandos, respuestas sin procesar del módem, flujos de inicio/continuación/cancelación de USSD y reporte claro de errores del módem. |
| SMS | Envío directo de SMS celulares e IMS, sincronización entrante, manejo multiparte, informes de entrega, historial de conversaciones, estado de no leído, marcas de tiempo y estado de entrega por mensaje. |
| WiFi Calling | Establecimiento de túnel IKEv2/ePDG, autenticación EAP-AKA, registro IMS, SMS IMS, controles de reconexión, diagnósticos de estado y enrutamiento por dispositivo. |
| eSIM y eUICC | Descubrimiento de eUICC, EID e información de producción, metadatos de certificados, inventario multi-eUICC, lista de perfiles instalados, operaciones de habilitar/deshabilitar/cambiar, y operaciones de descarga, renombrado y eliminación cuando la tarjeta lo admite. |
| Política de tarjeta | Comportamiento de WiFi Calling y modo avión basado en ICCID con aplicación inmediata de la política. |
| Enrutamiento por proxy | Enrutamiento SOCKS ascendente, vinculaciones de dispositivos, reglas por país, comprobaciones de accesibilidad TCP y comprobaciones UDP Associate para las rutas de datos de WiFi Calling. |
| Notificaciones | Reenvío de nuevos SMS entrantes a través de Telegram, Bark, correo electrónico, Pushplus y webhooks firmados. Cada SMS se entrega como una notificación individual. |
| Bot de Telegram | Estado del dispositivo, lista y cambio de perfiles instalados, controles de WiFi Calling y envío de SMS. Las acciones sensibles requieren confirmación del administrador. |
| Operaciones | Autenticación, protección CSRF, políticas de acceso, eventos de auditoría, registros en vivo, retención de registros, comprobaciones de salud, diseño adaptable, modo oscuro e interfaz de usuario en inglés/chino. |
| Distribución | Binarios estáticos de Linux, script de instalación systemd, autoactualización con verificación SHA-256, imagen Docker, publicación en GHCR y compilaciones de versión de GitHub Actions. |
## Hardware compatible
Vocat está dirigido a módulos Quectel basados en Qualcomm que exponen interfaces AT, QMI, serie y de red USB compatibles, incluyendo:
- Quectel EC20
- Quectel EC25
- Familia Quectel EG25
- Módulos EG600 compatibles y relacionados
Las funciones disponibles dependen del firmware del módulo, la composición USB, las capacidades SIM/eSIM, los controladores del host, la red de radio y la configuración del operador.
## Instalación
### Instalación en Linux con un clic
Como root (incluyendo OpenWrt/Kwrt, donde `sudo` normalmente está ausente):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
Desde un usuario normal en una distribución con sudo:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
Comprobar los prerrequisitos de VoWiFi/XFRM del host sin instalar VoCat:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
Instalar una versión específica:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
sudo bash install.sh 0.0.2
```
VoWiFi IMS requiere Linux XFRM/IPsec. En OpenWrt/Kwrt el instalador intenta
instalar los paquetes coincidentes `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
`kmod-crypto-authenc`, AES-CBC y SHA1 desde el propio feed del firmware.
Si no hay módulos de kernel coincidentes disponibles, use un firmware que los incluya;
nunca fuerce la instalación de kmods compilados para un kernel diferente.
El instalador:
- detecta `amd64`, `386`, `arm64`, `aarch64` o `armv7`;
- descarga el binario de GitHub Release correspondiente;
- lo verifica contra `SHA256SUMS`;
- instala Vocat en `/opt/vocat`;
- crea un servicio systemd reforzado con el acceso a hardware y red requerido por Vocat;
- almacena la configuración en tiempo de ejecución en `/etc/vocat/env`;
- genera una contraseña de administrador inicial aleatoria en la primera instalación.
Después de la instalación, abra:
```text
http://<dirección-del-servidor>:7575
```
### Instalación manual del binario
Descargue el binario correspondiente y `SHA256SUMS` desde GitHub Releases:
| Plataforma | Archivo de versión |
| --- | --- |
| Linux x86-64 | `vocat-linux-amd64` |
| Linux x86 32 bits | `vocat-linux-386` |
| Linux ARM64 | `vocat-linux-arm64` |
| Linux AArch64 | `vocat-linux-aarch64` |
| Linux ARMv7 | `vocat-linux-armv7` |
Verifíquelo e instálelo:
```bash
sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
/opt/vocat/bin/vocat serve
```
Este comando manual ejecuta Vocat en primer plano. Use `vocat serve` para que el
proceso inicie el servidor directamente; ejecutar `vocat` sin argumentos como root
en un TTY abre en su lugar el menú de gestión interactivo. Use el instalador de
un clic cuando se requiera un servicio systemd gestionado y reinicio automático.
### Docker
Para un host Linux que debe descubrir cada módem Quectel compatible conectado y
seguir viendo los eventos de conexión en caliente USB, ejecute Vocat en modo de acceso a hardware:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \
--name vocat \
--restart unless-stopped \
--network host \
--privileged \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
-v /dev:/dev \
-v /sys:/sys:ro \
ghcr.io/mengmengcode/vocat:latest
```
Abra `http://<dirección-del-servidor>:7575` después de que el contenedor se inicie. La red del host
es necesaria para que las interfaces de red QMI permanezcan visibles para Vocat, mientras que el
acceso privilegiado a dispositivos es necesario para los puertos serie, los nodos de control QMI,
las interfaces TUN, la configuración de red y los dispositivos añadidos después de que el contenedor
se inicie. El montaje bind de `/dev` hace visibles los nuevos nodos `ttyUSB*`, `ttyACM*` y `cdc-wdm*`
sin recrear el contenedor.
Este modo otorga intencionadamente a Vocat un amplio acceso a los dispositivos y a la pila de red
del host. Úselo solo en un host Linux de confianza. El descubrimiento automático identifica
actualmente los módems USB Quectel compatibles (ID de fabricante USB `2c7c`), no marcas de módems
arbitrarias. Mapear solo nodos individuales con `--device`, como `/dev/ttyUSB2` y `/dev/cdc-wdm0`,
limita el contenedor a esos nodos fijos y no proporciona un descubrimiento completo de múltiples
dispositivos o de conexión en caliente.
La imagen GHCR se publica para `linux/amd64` y `linux/arm64`.
## Configuración
Vocat lee un archivo de configuración JSON opcional desde `VOCAT_CONFIG` y luego aplica las variables de entorno `VOCAT_*`. Las variables de entorno tienen prioridad.
| Variable de entorno | Predeterminado | Descripción |
| --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | Dirección de escucha HTTP. |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Ruta de la base de datos SQLite. |
| `VOCAT_SESSION_TTL` | `24h` | Duración de la sesión de autenticación. |
| `VOCAT_SECURE_COOKIES` | `false` | Marca las cookies de sesión como seguras cuando se usa HTTPS. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Tiempo de espera de apagado ordenado. |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Tamaño máximo del cuerpo de solicitud de la API. |
| `VOCAT_REPO` | `MengMengCode/VoCat` | Repositorio de GitHub de confianza usado por el autoactualizador, en formato `owner/name`. |
| `GITHUB_TOKEN` | vacío | Token de GitHub opcional para repositorios privados o límites de API más altos. |
No almacene tokens de Telegram, contraseñas SMTP, secretos de webhook, credenciales SIM u otros datos privados en el repositorio. Configúrelos a través de los ajustes de la aplicación o archivos de entorno protegidos.
## Bot de Telegram
Cuando las notificaciones de Telegram están habilitadas y tanto el Chat ID como el Admin ID están configurados, el bot admite:
```text
/status [dispositivo]
/esim <dispositivo>
/switch <dispositivo> <iccid>
/wfc <dispositivo> <status|on|off|reconnect>
/sms <dispositivo> <número> <mensaje>
```
El cambio de perfil y el envío de SMS usan botones de confirmación de un solo uso. El bot no expone comandos de descarga, eliminación o renombrado de eSIM.
## Actualización
Comprobar si hay una GitHub Release más reciente:
```bash
vocat update --check --repo MengMengCode/VoCat
```
Instalar la última versión:
```bash
sudo vocat update --repo MengMengCode/VoCat
```
El actualizador descarga el binario que coincide con la arquitectura Linux actual, lo verifica con el `SHA256SUMS` publicado, reemplaza el ejecutable de forma atómica y reinicia el servicio systemd `vocat` cuando está disponible.
Para instalaciones Docker:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
```
Recrear el contenedor después de descargar la nueva imagen.
## Desarrollo
Requisitos:
- Go 1.25 o más reciente
- Node.js 20 o más reciente
- npm
Ejecutar el servidor de desarrollo del frontend:
```bash
cd web
npm install
npm run dev
```
Compilar el frontend incrustado e iniciar el backend:
```bash
cd web
npm run build
cd ..
go run ./cmd/vocat
```
Ejecutar todas las pruebas:
```bash
go test ./...
```
Compilar un binario de producción:
```bash
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
```
## Automatización de versiones
Hacer push de una etiqueta de versión inicia dos flujos de trabajo de GitHub Actions:
- `release-binaries` compila y publica los binarios `amd64`, `386`, `arm64`, `aarch64` y `armv7` más `SHA256SUMS`.
- `docker` compila y publica una imagen multiarquitectura en GitHub Container Registry.
```bash
git tag v0.2.0
git push origin v0.2.0
```
## Estructura del proyecto
```text
cmd/vocat/ Punto de entrada de la aplicación y CLI
internal/device/ Descubrimiento de módems y control de dispositivos
internal/modem/ Sesión AT y manejo de respuestas
internal/server/ API HTTP, notificaciones y servidor web incrustado
internal/store/ Persistencia SQLite
internal/update/ Autoactualizador de GitHub Release
internal/vowifi/ Runtime de IKE, EAP-AKA, IMS y WiFi Calling
scripts/install.sh Instalador y actualizador de Linux
web/src/ Frontend en React y TypeScript
.github/workflows/ Automatización de versiones de binarios y Docker
```
## Uso responsable
Las operaciones con módems celulares y eSIM pueden afectar el servicio del abonado, los perfiles almacenados, el registro de red y el estado del hardware. Mantenga copias de seguridad, revise con cuidado las acciones destructivas y use el software solo en entornos legales donde tenga permiso para operar el hardware y los recursos de red conectados.
Vocat no elude la autenticación del operador, la política de red, la seguridad del hardware ni los requisitos de confianza de eSIM. El soporte de una operación significa que Vocat puede solicitarla al módem o al eUICC; el dispositivo, el perfil, la red o el operador aún pueden rechazarla.
## Contribuir
Las incidencias y pull requests son bienvenidas. Mantenga los cambios enfocados, incluya pruebas cuando sea práctico, evite confirmar credenciales o datos de abonados, y documente claramente el comportamiento específico del hardware.
Antes de enviar un cambio:
```bash
go test ./...
cd web && npm run build
```
## Agradecimientos
- [Nodeseek.com](https://www.nodeseek.com) — Una comunidad dedicada a los servidores
- [Linux.do](https://linux.do) — Una comunidad tecnológica inspiradora
- [iniwex5](https://github.com/iniwex5) — Guías de estilo y funcionalidad
## Invítame a un café
| Red | Dirección |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## Licencia
Consulte [LICENSE](../LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+343
View File
@@ -0,0 +1,343 @@
<p align="center">
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
</p>
<h1 align="center">VoCat</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
</p>
<p align="center">
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p>
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | **Français** | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
Vocat est un panneau de contrôle web open-source et une boîte à outils d'ingénierie pour les modems cellulaires Quectel de classe EC20/EC25. Il réunit, dans un service autonome unique, la découverte de modems, l'état radio en direct, les terminaux AT et USSD, les SMS, la WiFi Calling, la gestion eSIM, la sélection de réseau, le routage par proxy, les notifications, les journaux d'audit et l'automatisation des versions.
Le backend est écrit en Go, l'interface est construite avec React et TypeScript, et le frontend de production est intégré dans le binaire Go. Un seul exécutable contient l'application web et utilise SQLite pour l'état persistant.
<p align="center">
<img src="../img/image.png">
<img src="../img/image-1.png">
</p>
## Fonctionnalités
| Domaine | Ce que Vocat fournit |
| --- | --- |
| Gestion des appareils | Découverte série/USB automatique, prise en charge de plusieurs modems, noms d'appareils conviviaux, mises à jour en direct de la vue d'ensemble, redémarrage du module, mode avion et contrôles du mode réseau USB. |
| Radio et réseau | État d'enregistrement, opérateur, métriques de signal, RSRP/RSRQ/SINR, mode réseau, bande, canal, recherche d'opérateurs et sélection de réseau automatique ou manuelle. |
| AT et USSD | Terminal AT interactif, historique des commandes, réponses brutes du modem, flux de démarrage/poursuite/annulation USSD et rapport d'erreurs modem clair. |
| SMS | Envoi direct de SMS cellulaires et IMS, synchronisation entrante, gestion des messages multiparties, rapports de livraison, historique des conversations, état non lu, horodatages et statut de livraison par message. |
| WiFi Calling | Établissement de tunnel IKEv2/ePDG, authentification EAP-AKA, enregistrement IMS, SMS IMS, contrôles de reconnexion, diagnostics d'état et routage par appareil. |
| eSIM et eUICC | Découverte eUICC, EID et informations de production, métadonnées de certificat, inventaire multi-eUICC, liste des profils installés, opérations d'activation/désactivation/commutation, ainsi que téléchargement, renommage et suppression lorsque la carte le permet. |
| Politique de carte | Comportement WiFi Calling et mode avion basé sur l'ICCID avec application immédiate de la politique. |
| Routage par proxy | Routage SOCKS amont, liaisons d'appareils, règles par pays, vérifications d'accessibilité TCP et vérifications UDP Associate pour les chemins de données WiFi Calling. |
| Notifications | Transfert des nouveaux SMS entrants via Telegram, Bark, e-mail, Pushplus et webhooks signés. Chaque SMS est livré comme une notification individuelle. |
| Bot Telegram | État de l'appareil, liste et commutation des profils installés, contrôles WiFi Calling et envoi de SMS. Les actions sensibles nécessitent une confirmation de l'administrateur. |
| Exploitation | Authentification, protection CSRF, politiques d'accès, événements d'audit, journaux en direct, rétention des journaux, vérifications de santé, mise en page réactive, mode sombre et interface utilisateur en anglais/chinois. |
| Distribution | Binaires Linux statiques, script d'installation systemd, auto-mise à jour avec vérification SHA-256, image Docker, publication GHCR et builds de version GitHub Actions. |
## Matériel pris en charge
Vocat cible les modules Quectel à base Qualcomm qui exposent des interfaces AT, QMI, série et réseau USB compatibles, notamment :
- Quectel EC20
- Quectel EC25
- Famille Quectel EG25
- Modules EG600 compatibles et apparentés
Les fonctionnalités disponibles dépendent du firmware du module, de la composition USB, des capacités SIM/eSIM, des pilotes hôtes, du réseau radio et de la configuration de l'opérateur.
## Installation
### Installation Linux en un clic
En tant que root (y compris OpenWrt/Kwrt, où `sudo` est normalement absent) :
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
Depuis un utilisateur normal sur une distribution disposant de sudo :
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
Vérifier les prérequis VoWiFi/XFRM de l'hôte sans installer VoCat :
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
Installer une version spécifique :
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
sudo bash install.sh 0.0.2
```
VoWiFi IMS nécessite Linux XFRM/IPsec. Sur OpenWrt/Kwrt, le programme d'installation tente
d'installer les paquets correspondants `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
`kmod-crypto-authenc`, AES-CBC et SHA1 depuis le dépôt du firmware lui-même.
Si des modules noyau correspondants ne sont pas disponibles, utilisez un firmware qui les inclut ;
ne forcez jamais l'installation de kmods compilés pour un noyau différent.
Le programme d'installation :
- détecte `amd64`, `386`, `arm64`, `aarch64` ou `armv7` ;
- télécharge le binaire GitHub Release correspondant ;
- le vérifie par rapport à `SHA256SUMS` ;
- installe Vocat sous `/opt/vocat` ;
- crée un service systemd renforcé disposant des accès matériel et réseau requis par Vocat ;
- stocke la configuration d'exécution dans `/etc/vocat/env` ;
- génère un mot de passe administrateur initial aléatoire lors de la première installation.
Après l'installation, ouvrez :
```text
http://<adresse-du-serveur>:7575
```
### Installation manuelle du binaire
Téléchargez le binaire correspondant et `SHA256SUMS` depuis GitHub Releases :
| Plateforme | Fichier de version |
| --- | --- |
| Linux x86-64 | `vocat-linux-amd64` |
| Linux x86 32 bits | `vocat-linux-386` |
| Linux ARM64 | `vocat-linux-arm64` |
| Linux AArch64 | `vocat-linux-aarch64` |
| Linux ARMv7 | `vocat-linux-armv7` |
Vérifiez-le et installez-le :
```bash
sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
/opt/vocat/bin/vocat serve
```
Cette commande manuelle exécute Vocat au premier plan. Utilisez `vocat serve` pour que le
processus démarre directement le serveur ; exécuter `vocat` sans argument en tant que root
sur un TTY ouvre plutôt le menu de gestion interactif. Utilisez le programme d'installation
en un clic lorsqu'un service systemd géré et un redémarrage automatique sont requis.
### Docker
Pour un hôte Linux qui doit découvrir chaque modem Quectel pris en charge connecté et
continuer à voir les événements de branchement à chaud USB, exécutez Vocat en mode d'accès matériel :
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \
--name vocat \
--restart unless-stopped \
--network host \
--privileged \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
-v /dev:/dev \
-v /sys:/sys:ro \
ghcr.io/mengmengcode/vocat:latest
```
Ouvrez `http://<adresse-du-serveur>:7575` après le démarrage du conteneur. Le réseau de l'hôte
est requis pour que les interfaces réseau QMI restent visibles par Vocat, tandis que l'accès
privilégié aux périphériques est requis pour les ports série, les nœuds de contrôle QMI, les
interfaces TUN, la configuration réseau et les périphériques ajoutés après le démarrage du
conteneur. Le montage bind de `/dev` rend les nouveaux nœuds `ttyUSB*`, `ttyACM*` et `cdc-wdm*`
visibles sans recréer le conteneur.
Ce mode donne intentionnellement à Vocat un large accès aux périphériques et à la pile réseau
de l'hôte. Ne l'utilisez que sur un hôte Linux de confiance. La découverte automatique
identifie actuellement les modems USB Quectel pris en charge (ID fabricant USB `2c7c`), et non
des marques de modems arbitraires. Le mappage de nœuds individuels uniquement avec `--device`,
comme `/dev/ttyUSB2` et `/dev/cdc-wdm0`, limite le conteneur à ces nœuds fixes et ne fournit
pas une découverte multi-périphériques ou à chaud complète.
L'image GHCR est publiée pour `linux/amd64` et `linux/arm64`.
## Configuration
Vocat lit un fichier de configuration JSON optionnel depuis `VOCAT_CONFIG`, puis applique les variables d'environnement `VOCAT_*`. Les variables d'environnement ont la priorité.
| Variable d'environnement | Par défaut | Description |
| --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | Adresse d'écoute HTTP. |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Chemin de la base de données SQLite. |
| `VOCAT_SESSION_TTL` | `24h` | Durée de vie de la session d'authentification. |
| `VOCAT_SECURE_COOKIES` | `false` | Marque les cookies de session comme sécurisés lorsque HTTPS est utilisé. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Délai d'arrêt gracieux. |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Taille maximale du corps de requête API. |
| `VOCAT_REPO` | `MengMengCode/VoCat` | Dépôt GitHub de confiance utilisé par l'auto-updater, au format `owner/name`. |
| `GITHUB_TOKEN` | vide | Jeton GitHub optionnel pour les dépôts privés ou des limites d'API plus élevées. |
Ne stockez pas de jetons Telegram, mots de passe SMTP, secrets de webhook, identifiants SIM ou autres données privées dans le dépôt. Configurez-les via les paramètres de l'application ou des fichiers d'environnement protégés.
## Bot Telegram
Lorsque les notifications Telegram sont activées et que le Chat ID et l'Admin ID sont configurés, le bot prend en charge :
```text
/status [appareil]
/esim <appareil>
/switch <appareil> <iccid>
/wfc <appareil> <status|on|off|reconnect>
/sms <appareil> <numéro> <message>
```
La commutation de profil et l'envoi de SMS utilisent des boutons de confirmation à usage unique. Le bot n'expose pas les commandes de téléchargement, de suppression ou de renommage eSIM.
## Mise à jour
Vérifier l'existence d'une GitHub Release plus récente :
```bash
vocat update --check --repo MengMengCode/VoCat
```
Installer la dernière version :
```bash
sudo vocat update --repo MengMengCode/VoCat
```
L'updater télécharge le binaire correspondant à l'architecture Linux actuelle, le vérifie avec le `SHA256SUMS` publié, remplace l'exécutable de manière atomique et redémarre le service systemd `vocat` lorsqu'il est disponible.
Pour les installations Docker :
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
```
Recréez le conteneur après avoir tiré la nouvelle image.
## Développement
Prérequis :
- Go 1.25 ou plus récent
- Node.js 20 ou plus récent
- npm
Lancer le serveur de développement frontend :
```bash
cd web
npm install
npm run dev
```
Construire le frontend intégré et démarrer le backend :
```bash
cd web
npm run build
cd ..
go run ./cmd/vocat
```
Exécuter tous les tests :
```bash
go test ./...
```
Construire un binaire de production :
```bash
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
```
## Automatisation des versions
Pousser un tag de version déclenche deux workflows GitHub Actions :
- `release-binaries` construit et publie les binaires `amd64`, `386`, `arm64`, `aarch64` et `armv7` ainsi que `SHA256SUMS`.
- `docker` construit et publie une image multi-architecture vers GitHub Container Registry.
```bash
git tag v0.2.0
git push origin v0.2.0
```
## Structure du projet
```text
cmd/vocat/ Point d'entrée de l'application et CLI
internal/device/ Découverte de modems et contrôle des appareils
internal/modem/ Session AT et gestion des réponses
internal/server/ API HTTP, notifications et serveur web intégré
internal/store/ Persistance SQLite
internal/update/ Auto-updater GitHub Release
internal/vowifi/ Runtime IKE, EAP-AKA, IMS et WiFi Calling
scripts/install.sh Installeur et updater Linux
web/src/ Frontend React et TypeScript
.github/workflows/ Automatisation des versions binaires et Docker
```
## Utilisation responsable
Les opérations sur les modems cellulaires et les eSIM peuvent affecter le service de l'abonné, les profils stockés, l'enregistrement réseau et l'état du matériel. Effectuez des sauvegardes, examinez attentivement les actions destructrices et n'utilisez le logiciel que dans des environnements légaux où vous êtes autorisé à exploiter le matériel et les ressources réseau connectés.
Vocat ne contourne ni l'authentification de l'opérateur, ni la politique réseau, ni la sécurité matérielle, ni les exigences de confiance eSIM. La prise en charge d'une opération signifie que Vocat peut la demander au modem ou à l'eUICC ; l'appareil, le profil, le réseau ou l'opérateur peut toujours la refuser.
## Contribution
Les issues et pull requests sont les bienvenues. Gardez des changements ciblés, incluez des tests lorsque c'est possible, évitez de committer des identifiants ou des données d'abonnés, et documentez clairement les comportements spécifiques au matériel.
Avant de soumettre un changement :
```bash
go test ./...
cd web && npm run build
```
## Remerciements
- [Nodeseek.com](https://www.nodeseek.com) — Une communauté dédiée aux serveurs
- [Linux.do](https://linux.do) — Une communauté technologique inspirante
- [iniwex5](https://github.com/iniwex5) — Directives de style et de fonctionnalité
## Offrez-moi un café
| Réseau | Adresse |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## Licence
Voir [LICENSE](../LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+325
View File
@@ -0,0 +1,325 @@
<p align="center">
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
</p>
<h1 align="center">VoCat</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
</p>
<p align="center">
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p>
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | **日本語**
Vocat は、Quectel EC20/EC25 クラスのセルラーモデム向けのオープンソース Web コントロールパネル兼エンジニアリングツールキットです。モデムの検出、ライブの無線ステータス、AT / USSD ターミナル、SMS、WiFi Calling、eSIM 管理、ネットワーク選択、プロキシルーティング、通知、監査ログ、リリース自動化を、自己完結型の単一サービスに統合しています。
バックエンドは Go で書かれ、インターフェースは React と TypeScript で構築され、本番フロントエンドは Go バイナリに埋め込まれています。単一の実行ファイルに Web アプリケーション全体が含まれ、永続的な状態には SQLite を使用します。
<p align="center">
<img src="../img/image.png">
<img src="../img/image-1.png">
</p>
## 機能
| 領域 | Vocat が提供する機能 |
| --- | --- |
| デバイス管理 | シリアル/USB の自動検出、複数モデムのサポート、わかりやすいデバイス名、概要のライブ更新、モジュールの再起動、機内モード、USB ネットワークモードの制御。 |
| 無線とネットワーク | 登録ステータス、オペレーター、信号指標、RSRP/RSRQ/SINR、ネットワークモード、バンド、チャネル、オペレータースキャン、自動または手動のネットワーク選択。 |
| AT と USSD | 対話型 AT ターミナル、コマンド履歴、モデムの生出力、USSD の開始/継続/キャンセルフロー、明確なモデムエラーレポート。 |
| SMS | セルラーおよび IMS SMS の直接送信、受信同期、マルチパート処理、配信レポート、会話履歴、未読状態、タイムスタンプ、メッセージごとの配信ステータス。 |
| WiFi Calling | IKEv2/ePDG トンネルの確立、EAP-AKA 認証、IMS 登録、IMS SMS、再接続制御、ステータス診断、デバイスごとのルーティング。 |
| eSIM と eUICC | eUICC の検出、EID と製造情報、証明書メタデータ、複数 eUICC のインベントリ、インストール済みプロファイルの一覧、有効化/無効化/切り替え操作、およびカードが対応している場合のダウンロード、名前変更、削除操作。 |
| カードポリシー | ICCID ベースの WiFi Calling および機内モードの動作で、ポリシーが即時に適用されます。 |
| プロキシルーティング | アップストリーム SOCKS ルーティング、デバイスバインディング、国別ルール、TCP 到達性チェック、WiFi Calling データパス向けの UDP Associate チェック。 |
| 通知 | Telegram、Bark、メール、Pushplus、署名付き Webhook を介した新着 SMS の転送。各 SMS は個別の通知として配信されます。 |
| Telegram ボット | デバイスステータス、インストール済みプロファイルの一覧と切り替え、WiFi Calling 制御、SMS 送信。機密性の高い操作には管理者の確認が必要です。 |
| 運用 | 認証、CSRF 保護、アクセスポリシー、監査イベント、ライブログ、ログ保持、ヘルスチェック、レスポンシブレイアウト、ダークモード、英語/中国語のアプリケーション UI。 |
| 配布 | 静的 Linux バイナリ、systemd インストールスクリプト、SHA-256 検証付きの自己更新、Docker イメージ、GHCR 公開、GitHub Actions リリースビルド。 |
## 対応ハードウェア
Vocat は、互換性のある AT、QMI、シリアル、USB ネットワークインターフェースを公開する Qualcomm ベースの Quectel モジュールを対象としています。対象には以下が含まれます:
- Quectel EC20
- Quectel EC25
- Quectel EG25 ファミリー
- 互換性のある EG600 および関連モジュール
利用可能な機能は、モジュールのファームウェア、USB 構成、SIM/eSIM の機能、ホストドライバー、無線ネットワーク、キャリア設定によって異なります。
## インストール
### ワンクリック Linux インストール
root として(`sudo` が通常存在しない OpenWrt/Kwrt を含む):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
sudo を持つディストリビューションの一般ユーザーから:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
VoCat をインストールせずに、ホストの VoWiFi/XFRM 前提条件を確認する:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
特定のバージョンをインストールする:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
sudo bash install.sh 0.0.2
```
VoWiFi IMS には Linux XFRM/IPsec が必要です。OpenWrt/Kwrt では、インストーラーはファームウェア自身のフィードから、一致する `ip-full``kmod-ipsec``kmod-ipsec4/6``kmod-crypto-authenc`、AES-CBC、SHA1 パッケージのインストールを試みます。一致するカーネルモジュールが利用できない場合は、それらを含むファームウェアを使用してください。別のカーネル向けにビルドされた kmod を強制的にインストールしてはいけません。
インストーラーは次を行います:
- `amd64``386``arm64``aarch64``armv7` を検出します;
- 一致する GitHub Release バイナリをダウンロードします;
- `SHA256SUMS` と照合して検証します;
- Vocat を `/opt/vocat` にインストールします;
- Vocat が必要とするハードウェアおよびネットワークアクセスを持つ強化された systemd サービスを作成します;
- 実行時設定を `/etc/vocat/env` に保存します;
- 初回インストール時にランダムな初期管理者パスワードを生成します。
インストール後、次を開きます:
```text
http://<サーバーアドレス>:7575
```
### 手動バイナリインストール
一致するバイナリと `SHA256SUMS` を GitHub Releases からダウンロードします:
| プラットフォーム | リリースファイル |
| --- | --- |
| Linux x86-64 | `vocat-linux-amd64` |
| Linux x86 32 ビット | `vocat-linux-386` |
| Linux ARM64 | `vocat-linux-arm64` |
| Linux AArch64 | `vocat-linux-aarch64` |
| Linux ARMv7 | `vocat-linux-armv7` |
検証してインストールします:
```bash
sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
/opt/vocat/bin/vocat serve
```
この手動コマンドは Vocat をフォアグラウンドで実行します。プロセスがサーバーを直接起動するように `vocat serve` を使用してください。TTY で root として引数なしで `vocat` を実行すると、代わりに対話型管理メニューが開きます。管理対象の systemd サービスと自動再起動が必要な場合は、ワンクリックインストーラーを使用してください。
### Docker
接続されているすべてのサポート対象 Quectel モデムを検出し、USB ホットプラグイベントを継続的に認識する必要がある Linux ホストでは、Vocat をハードウェアアクセスモードで実行します:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \
--name vocat \
--restart unless-stopped \
--network host \
--privileged \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
-v /dev:/dev \
-v /sys:/sys:ro \
ghcr.io/mengmengcode/vocat:latest
```
コンテナの起動後に `http://<サーバーアドレス>:7575` を開きます。QMI ネットワークインターフェースが Vocat から見えるようにするにはホストネットワークが必要であり、シリアルポート、QMI 制御ノード、TUN インターフェース、ネットワーク設定、コンテナ起動後に追加されたデバイスには特権デバイスアクセスが必要です。`/dev` バインドマウントにより、コンテナを再作成せずに新しい `ttyUSB*``ttyACM*``cdc-wdm*` ノードが見えるようになります。
このモードは意図的に Vocat にホストのデバイスとネットワークスタックへの広範なアクセスを付与します。信頼できる Linux ホストでのみ使用してください。自動検出は現在、サポート対象の Quectel USB モデム(USB ベンダー ID `2c7c`)のみを識別し、任意のモデムブランドは識別しません。`--device``/dev/ttyUSB2``/dev/cdc-wdm0` などの個別ノードのみをマッピングすると、コンテナはそれらの固定ノードに限定され、完全なマルチデバイスまたはホットプラグ検出は提供されません。
GHCR イメージは `linux/amd64``linux/arm64` 向けに公開されています。
## 設定
Vocat は `VOCAT_CONFIG` からオプションの JSON 設定ファイルを読み込み、次に `VOCAT_*` 環境変数を適用します。環境変数が優先されます。
| 環境変数 | デフォルト | 説明 |
| --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP リッスンアドレス。 |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite データベースパス。 |
| `VOCAT_SESSION_TTL` | `24h` | 認証セッションの有効期間。 |
| `VOCAT_SECURE_COOKIES` | `false` | HTTPS 使用時にセッション Cookie をセキュアとしてマークします。 |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | グレースフルシャットダウンのタイムアウト。 |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | API リクエストボディの最大サイズ。 |
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自己更新機能が使用する信頼された GitHub リポジトリ(`owner/name` 形式)。 |
| `GITHUB_TOKEN` | 空 | プライベートリポジトリやより高い API レート制限のためのオプションの GitHub トークン。 |
Telegram トークン、SMTP パスワード、Webhook シークレット、SIM 認証情報、その他のプライベートデータをリポジトリに保存しないでください。アプリケーション設定または保護された環境ファイルを通じて設定してください。
## Telegram ボット
Telegram 通知が有効で、Chat ID と Admin ID の両方が設定されている場合、ボットは以下をサポートします:
```text
/status [デバイス]
/esim <デバイス>
/switch <デバイス> <iccid>
/wfc <デバイス> <status|on|off|reconnect>
/sms <デバイス> <番号> <メッセージ>
```
プロファイルの切り替えと SMS の送信には、ワンタイム確認ボタンが使用されます。ボットは eSIM のダウンロード、削除、名前変更コマンドを公開しません。
## 更新
より新しい GitHub Release を確認する:
```bash
vocat update --check --repo MengMengCode/VoCat
```
最新リリースをインストールする:
```bash
sudo vocat update --repo MengMengCode/VoCat
```
アップデーターは、現在の Linux アーキテクチャに一致するバイナリをダウンロードし、公開された `SHA256SUMS` で検証し、実行ファイルをアトミックに置き換え、利用可能な場合は `vocat` systemd サービスを再起動します。
Docker インストールの場合:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
```
新しいイメージをプルした後、コンテナを再作成します。
## 開発
要件:
- Go 1.25 以降
- Node.js 20 以降
- npm
フロントエンド開発サーバーを実行する:
```bash
cd web
npm install
npm run dev
```
埋め込みフロントエンドをビルドしてバックエンドを起動する:
```bash
cd web
npm run build
cd ..
go run ./cmd/vocat
```
すべてのテストを実行する:
```bash
go test ./...
```
本番バイナリをビルドする:
```bash
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
```
## リリース自動化
バージョンタグをプッシュすると、2 つの GitHub Actions ワークフローが開始されます:
- `release-binaries``amd64``386``arm64``aarch64``armv7` バイナリと `SHA256SUMS` をビルドして公開します。
- `docker` はマルチアーキテクチャイメージをビルドして GitHub Container Registry に公開します。
```bash
git tag v0.2.0
git push origin v0.2.0
```
## プロジェクト構成
```text
cmd/vocat/ アプリケーションのエントリポイントと CLI
internal/device/ モデム検出とデバイス制御
internal/modem/ AT セッションと応答処理
internal/server/ HTTP API、通知、埋め込み Web サーバー
internal/store/ SQLite 永続化
internal/update/ GitHub Release 自己更新機能
internal/vowifi/ IKE、EAP-AKA、IMS、WiFi Calling ランタイム
scripts/install.sh Linux インストーラーとアップデーター
web/src/ React と TypeScript のフロントエンド
.github/workflows/ バイナリと Docker のリリース自動化
```
## 責任ある使用
セルラーモデムおよび eSIM の操作は、加入者サービス、保存されたプロファイル、ネットワーク登録、ハードウェア状態に影響を与える可能性があります。バックアップを保持し、破壊的な操作を慎重に確認し、接続されたハードウェアとネットワークリソースを操作することが許可されている合法的な環境でのみソフトウェアを使用してください。
Vocat は、キャリア認証、ネットワークポリシー、ハードウェアセキュリティ、eSIM の信頼要件をバイパスしません。操作のサポートは、Vocat がモデムまたは eUICC にそれを要求できることを意味します。デバイス、プロファイル、ネットワーク、またはキャリアがそれを拒否する場合があります。
## コントリビューション
Issue や Pull Request を歓迎します。変更は焦点を絞り、可能な場合はテストを含め、認証情報や加入者データをコミットしないようにし、ハードウェア固有の動作を明確に文書化してください。
変更を提出する前に:
```bash
go test ./...
cd web && npm run build
```
## 謝辞
- [Nodeseek.com](https://www.nodeseek.com) — サーバーに特化したコミュニティ
- [Linux.do](https://linux.do) — 刺激的なテックコミュニティ
- [iniwex5](https://github.com/iniwex5) — スタイルと機能のガイドライン
## コーヒーをおごってください
| ネットワーク | アドレス |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## ライセンス
[LICENSE](../LICENSE) を参照してください。
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+342
View File
@@ -0,0 +1,342 @@
<p align="center">
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
</p>
<h1 align="center">VoCat</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
</p>
<p align="center">
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p>
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | **Русский** | [Español](README.es.md) | [日本語](README.ja.md)
Vocat — это веб-панель управления с открытым исходным кодом и набор инженерных инструментов для сотовых модемов Quectel класса EC20/EC25. Она объединяет в одном автономном сервисе обнаружение модемов, состояние радиосвязи в реальном времени, терминалы AT и USSD, SMS, WiFi Calling, управление eSIM, выбор сети, маршрутизацию через прокси, уведомления, журналы аудита и автоматизацию релизов.
Бэкенд написан на Go, интерфейс построен на React и TypeScript, а производственный фронтенд встроен в бинарный файл Go. Один исполняемый файл содержит веб-приложение и использует SQLite для постоянного хранения состояния.
<p align="center">
<img src="../img/image.png">
<img src="../img/image-1.png">
</p>
## Возможности
| Область | Что предоставляет Vocat |
| --- | --- |
| Управление устройствами | Автоматическое обнаружение по последовательному порту/USB, поддержка нескольких модемов, понятные имена устройств, обновление обзора в реальном времени, перезапуск модуля, авиарежим и управление режимом USB-сети. |
| Радио и сеть | Статус регистрации, оператор, метрики сигнала, RSRP/RSRQ/SINR, режим сети, диапазон, канал, сканирование операторов и автоматический или ручной выбор сети. |
| AT и USSD | Интерактивный AT-терминал, история команд, необработанные ответы модема, потоки запуска/продолжения/отмены USSD и понятные сообщения об ошибках модема. |
| SMS | Прямая отправка сотовых и IMS SMS, входящая синхронизация, обработка составных сообщений, отчёты о доставке, история диалогов, статус непрочитанных, метки времени и статус доставки каждого сообщения. |
| WiFi Calling | Установка туннеля IKEv2/ePDG, аутентификация EAP-AKA, регистрация IMS, IMS SMS, управление переподключением, диагностика состояния и маршрутизация по устройствам. |
| eSIM и eUICC | Обнаружение eUICC, EID и производственная информация, метаданные сертификатов, инвентарь нескольких eUICC, список установленных профилей, операции включения/отключения/переключения, а также загрузка, переименование и удаление при поддержке картой. |
| Политика карты | Поведение WiFi Calling и авиарежима на основе ICCID с немедленным применением политики. |
| Маршрутизация через прокси | Восходящая маршрутизация SOCKS, привязки устройств, правила по странам, проверки доступности TCP и проверки UDP Associate для путей передачи данных WiFi Calling. |
| Уведомления | Пересылка новых входящих SMS через Telegram, Bark, электронную почту, Pushplus и подписанные вебхуки. Каждое SMS доставляется как отдельное уведомление. |
| Telegram-бот | Статус устройства, список и переключение установленных профилей, управление WiFi Calling и отправка SMS. Чувствительные действия требуют подтверждения администратора. |
| Эксплуатация | Аутентификация, защита CSRF, политики доступа, события аудита, журналы в реальном времени, хранение журналов, проверки работоспособности, адаптивная вёрстка, тёмный режим и интерфейс на английском/китайском. |
| Дистрибуция | Статические бинарные файлы Linux, скрипт установки systemd, самообновление с проверкой SHA-256, образ Docker, публикация в GHCR и сборки релизов GitHub Actions. |
## Поддерживаемое оборудование
Vocat ориентирован на модули Quectel на базе Qualcomm, которые предоставляют совместимые интерфейсы AT, QMI, последовательный порт и USB-сеть, включая:
- Quectel EC20
- Quectel EC25
- Семейство Quectel EG25
- Совместимые модули EG600 и родственные
Доступные функции зависят от прошивки модуля, конфигурации USB, возможностей SIM/eSIM, драйверов хоста, радиосети и настроек оператора.
## Установка
### Установка в Linux одной командой
От имени root (включая OpenWrt/Kwrt, где `sudo` обычно отсутствует):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
От обычного пользователя в дистрибутиве с sudo:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
Проверить предварительные требования VoWiFi/XFRM на хосте без установки VoCat:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
Установить конкретную версию:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
sudo bash install.sh 0.0.2
```
VoWiFi IMS требует Linux XFRM/IPsec. В OpenWrt/Kwrt установщик пытается
установить соответствующие пакеты `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
`kmod-crypto-authenc`, AES-CBC и SHA1 из собственного репозитория прошивки.
Если соответствующие модули ядра недоступны, используйте прошивку, которая их включает;
никогда не устанавливайте принудительно kmod, собранные для другого ядра.
Установщик:
- определяет `amd64`, `386`, `arm64`, `aarch64` или `armv7`;
- загружает соответствующий бинарный файл GitHub Release;
- проверяет его по `SHA256SUMS`;
- устанавливает Vocat в `/opt/vocat`;
- создаёт усиленный сервис systemd с доступом к оборудованию и сети, необходимым Vocat;
- хранит конфигурацию времени выполнения в `/etc/vocat/env`;
- генерирует случайный начальный пароль администратора при первой установке.
После установки откройте:
```text
http://<адрес-сервера>:7575
```
### Ручная установка бинарного файла
Загрузите соответствующий бинарный файл и `SHA256SUMS` из GitHub Releases:
| Платформа | Файл релиза |
| --- | --- |
| Linux x86-64 | `vocat-linux-amd64` |
| Linux x86 32-бит | `vocat-linux-386` |
| Linux ARM64 | `vocat-linux-arm64` |
| Linux AArch64 | `vocat-linux-aarch64` |
| Linux ARMv7 | `vocat-linux-armv7` |
Проверьте и установите его:
```bash
sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
/opt/vocat/bin/vocat serve
```
Эта ручная команда запускает Vocat в переднем плане. Используйте `vocat serve`, чтобы
процесс сразу запустил сервер; запуск `vocat` без аргументов от имени root
в TTY вместо этого открывает интерактивное меню управления. Используйте установку
одной командой, когда требуется управляемый сервис systemd и автоматический перезапуск.
### Docker
Для хоста Linux, который должен обнаруживать каждый подключённый поддерживаемый модем Quectel и
продолжать видеть события горячего подключения USB, запустите Vocat в режиме доступа к оборудованию:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \
--name vocat \
--restart unless-stopped \
--network host \
--privileged \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
-v /dev:/dev \
-v /sys:/sys:ro \
ghcr.io/mengmengcode/vocat:latest
```
Откройте `http://<адрес-сервера>:7575` после запуска контейнера. Сеть хоста
необходима, чтобы сетевые интерфейсы QMI оставались видимыми для Vocat, а привилегированный
доступ к устройствам необходим для последовательных портов, узлов управления QMI, интерфейсов
TUN, настройки сети и устройств, добавленных после запуска контейнера. Монтирование `/dev`
делает новые узлы `ttyUSB*`, `ttyACM*` и `cdc-wdm*` видимыми без пересоздания контейнера.
Этот режим намеренно предоставляет Vocat широкий доступ к устройствам и сетевому стеку
хоста. Используйте его только на доверенном хосте Linux. Автоматическое обнаружение
в настоящее время определяет поддерживаемые USB-модемы Quectel (USB vendor ID `2c7c`), а не
произвольные марки модемов. Монтирование только отдельных узлов с помощью `--device`, таких как
`/dev/ttyUSB2` и `/dev/cdc-wdm0`, ограничивает контейнер этими фиксированными узлами и не
обеспечивает полное обнаружение нескольких устройств или горячего подключения.
Образ GHCR публикуется для `linux/amd64` и `linux/arm64`.
## Конфигурация
Vocat читает необязательный JSON-файл конфигурации из `VOCAT_CONFIG`, затем применяет переменные окружения `VOCAT_*`. Переменные окружения имеют приоритет.
| Переменная окружения | По умолчанию | Описание |
| --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | Адрес прослушивания HTTP. |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Путь к базе данных SQLite. |
| `VOCAT_SESSION_TTL` | `24h` | Время жизни сессии аутентификации. |
| `VOCAT_SECURE_COOKIES` | `false` | Помечает cookie сессии как безопасные при использовании HTTPS. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Тайм-аут корректного завершения работы. |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Максимальный размер тела запроса API. |
| `VOCAT_REPO` | `MengMengCode/VoCat` | Доверенный репозиторий GitHub, используемый самообновлятором, в формате `owner/name`. |
| `GITHUB_TOKEN` | пусто | Необязательный токен GitHub для приватных репозиториев или более высоких лимитов API. |
Не храните токены Telegram, пароли SMTP, секреты вебхуков, учётные данные SIM или другие приватные данные в репозитории. Настраивайте их через параметры приложения или защищённые файлы окружения.
## Telegram-бот
Когда уведомления Telegram включены и настроены Chat ID и Admin ID, бот поддерживает:
```text
/status [устройство]
/esim <устройство>
/switch <устройство> <iccid>
/wfc <устройство> <status|on|off|reconnect>
/sms <устройство> <номер> <сообщение>
```
Переключение профилей и отправка SMS используют одноразовые кнопки подтверждения. Бот не предоставляет команды загрузки, удаления или переименования eSIM.
## Обновление
Проверить наличие более нового GitHub Release:
```bash
vocat update --check --repo MengMengCode/VoCat
```
Установить последний релиз:
```bash
sudo vocat update --repo MengMengCode/VoCat
```
Обновлятор загружает бинарный файл, соответствующий текущей архитектуре Linux, проверяет его по опубликованному `SHA256SUMS`, атомарно заменяет исполняемый файл и перезапускает сервис systemd `vocat`, когда он доступен.
Для установок Docker:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
```
Пересоздайте контейнер после загрузки нового образа.
## Разработка
Требования:
- Go 1.25 или новее
- Node.js 20 или новее
- npm
Запустить сервер разработки фронтенда:
```bash
cd web
npm install
npm run dev
```
Собрать встроенный фронтенд и запустить бэкенд:
```bash
cd web
npm run build
cd ..
go run ./cmd/vocat
```
Запустить все тесты:
```bash
go test ./...
```
Собрать производственный бинарный файл:
```bash
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
```
## Автоматизация релизов
Отправка тега версии запускает два рабочих процесса GitHub Actions:
- `release-binaries` собирает и публикует бинарные файлы `amd64`, `386`, `arm64`, `aarch64` и `armv7` вместе с `SHA256SUMS`.
- `docker` собирает и публикует мультиархитектурный образ в GitHub Container Registry.
```bash
git tag v0.2.0
git push origin v0.2.0
```
## Структура проекта
```text
cmd/vocat/ Точка входа приложения и CLI
internal/device/ Обнаружение модемов и управление устройствами
internal/modem/ Сессия AT и обработка ответов
internal/server/ HTTP API, уведомления и встроенный веб-сервер
internal/store/ Постоянное хранение SQLite
internal/update/ Самообновлятор GitHub Release
internal/vowifi/ Среда выполнения IKE, EAP-AKA, IMS и WiFi Calling
scripts/install.sh Установщик и обновлятор Linux
web/src/ Фронтенд на React и TypeScript
.github/workflows/ Автоматизация релизов бинарных файлов и Docker
```
## Ответственное использование
Операции с сотовыми модемами и eSIM могут влиять на обслуживание абонента, сохранённые профили, регистрацию в сети и состояние оборудования. Делайте резервные копии, внимательно проверяйте деструктивные действия и используйте программное обеспечение только в законных средах, где вам разрешено работать с подключённым оборудованием и сетевыми ресурсами.
Vocat не обходит аутентификацию оператора, сетевую политику, аппаратную безопасность или требования доверия eSIM. Поддержка операции означает, что Vocat может запросить её у модема или eUICC; устройство, профиль, сеть или оператор всё равно могут её отклонить.
## Участие в разработке
Мы приветствуем issues и pull request'ы. Делайте изменения сфокусированными, по возможности добавляйте тесты, избегайте коммита учётных данных или данных абонентов и чётко документируйте поведение, специфичное для оборудования.
Перед отправкой изменения:
```bash
go test ./...
cd web && npm run build
```
## Благодарности
- [Nodeseek.com](https://www.nodeseek.com) — Сообщество, посвящённое серверам
- [Linux.do](https://linux.do) — Вдохновляющее технологическое сообщество
- [iniwex5](https://github.com/iniwex5) — Руководства по стилю и функциональности
## Угостите меня кофе
| Сеть | Адрес |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## Лицензия
См. [LICENSE](../LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+40 -11
View File
@@ -22,7 +22,7 @@
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white"> <img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p> </p>
[English](../README.md) | **简体中文** [English](../README.md) | [العربية](README.ar.md) | **简体中文** | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制面板与工程工具套件。它在一个自包含的服务中整合了模组发现、实时射频状态、AT 与 USSD 终端、短信、WiFi Calling(WiFi 通话)、eSIM 管理、网络选择、代理路由、通知、审计日志以及发布自动化。 Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制面板与工程工具套件。它在一个自包含的服务中整合了模组发现、实时射频状态、AT 与 USSD 终端、短信、WiFi Calling(WiFi 通话)、eSIM 管理、网络选择、代理路由、通知、审计日志以及发布自动化。
@@ -46,7 +46,7 @@ Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制
| 卡策略 | 基于 ICCID 的 WiFi Calling 与飞行模式行为,策略即时应用。 | | 卡策略 | 基于 ICCID 的 WiFi Calling 与飞行模式行为,策略即时应用。 |
| 代理路由 | 上游 SOCKS 路由、设备绑定、国家规则、TCP 可达性检查以及面向 WiFi Calling 数据路径的 UDP Associate 检查。 | | 代理路由 | 上游 SOCKS 路由、设备绑定、国家规则、TCP 可达性检查以及面向 WiFi Calling 数据路径的 UDP Associate 检查。 |
| 通知 | 通过 Telegram、Bark、邮件、Pushplus 以及签名 Webhook 转发新入站短信,每条短信单独推送。 | | 通知 | 通过 Telegram、Bark、邮件、Pushplus 以及签名 Webhook 转发新入站短信,每条短信单独推送。 |
| Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制短信发送、定时拨号并自动挂断、通话状态、接听与挂断命令。敏感操作需要管理员确认。 | | Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制以及短信发送。敏感操作需要管理员确认。 |
| 运维 | 鉴权、CSRF 防护、访问策略、审计事件、实时日志、日志留存、健康检查、响应式布局、深色模式以及中英文应用界面。 | | 运维 | 鉴权、CSRF 防护、访问策略、审计事件、实时日志、日志留存、健康检查、响应式布局、深色模式以及中英文应用界面。 |
| 分发 | 静态 Linux 二进制、systemd 安装脚本、带 SHA-256 校验的自更新、Docker 镜像、GHCR 发布以及 GitHub Actions 发布构建。 | | 分发 | 静态 Linux 二进制、systemd 安装脚本、带 SHA-256 校验的自更新、Docker 镜像、GHCR 发布以及 GitHub Actions 发布构建。 |
@@ -65,10 +65,24 @@ Vocat 面向基于高通芯片、并暴露兼容 AT、QMI、串口与 USB 网络
### Linux 一键安装 ### Linux 一键安装
已是 root(包括默认没有 `sudo` 的 OpenWrt/Kwrt):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
普通 Linux 用户且系统装有 sudo
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
``` ```
只检查 VoWiFi/XFRM 环境,不安装 VoCat
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
安装指定版本: 安装指定版本:
```bash ```bash
@@ -76,6 +90,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
sudo bash install.sh 0.0.2 sudo bash install.sh 0.0.2
``` ```
VoWiFi IMS 必须使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安装脚本会从当前固件自己的软件源尝试安装严格匹配的 `ip-full``kmod-ipsec``kmod-ipsec4/6``kmod-crypto-authenc`、AES-CBC 和 SHA1 组件。若软件源没有与当前内核匹配的模块,必须更换包含这些组件的固件,禁止强装其他内核版本的 kmod。
安装程序会: 安装程序会:
- 检测 `amd64``386``arm64``armv7` 架构; - 检测 `amd64``386``arm64``armv7` 架构;
@@ -109,9 +125,11 @@ http://<服务器地址>:7575
sha256sum -c SHA256SUMS --ignore-missing sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "管理员密码: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \ sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \ VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
VOCAT_ADMIN_PASSWORD=change-this-password \
/opt/vocat/bin/vocat serve /opt/vocat/bin/vocat serve
``` ```
@@ -124,13 +142,20 @@ sudo env \
```bash ```bash
docker pull ghcr.io/mengmengcode/vocat:latest docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "管理员密码: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \ docker run -d \
--name vocat \ --name vocat \
--restart unless-stopped \ --restart unless-stopped \
--network host \ --network host \
--privileged \ --privileged \
--user 0:0 \ --user 0:0 \
-e VOCAT_ADMIN_PASSWORD=change-this-password \
-v vocat-data:/opt/vocat/data \ -v vocat-data:/opt/vocat/data \
-v /dev:/dev \ -v /dev:/dev \
-v /sys:/sys:ro \ -v /sys:/sys:ro \
@@ -143,6 +168,13 @@ docker run -d \
GHCR 镜像发布为 `linux/amd64``linux/arm64` GHCR 镜像发布为 `linux/amd64``linux/arm64`
### USB SIM 读卡器
USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支持的软件包管理器上
自动安装并启动 `pcscd` 和 CCID 驱动;Debian/Ubuntu 手动安装命令为
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。
## 配置 ## 配置
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。 Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
@@ -151,8 +183,6 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
| --- | --- | --- | | --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP 监听地址。 | | `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP 监听地址。 |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite 数据库路径。 | | `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite 数据库路径。 |
| `VOCAT_ADMIN_USERNAME` | `admin` | 初始管理员用户名。 |
| `VOCAT_ADMIN_PASSWORD` | `admin` | 初始管理员密码。暴露服务前请务必修改。 |
| `VOCAT_SESSION_TTL` | `24h` | 鉴权会话有效期。 | | `VOCAT_SESSION_TTL` | `24h` | 鉴权会话有效期。 |
| `VOCAT_SECURE_COOKIES` | `false` | 在使用 HTTPS 时将会话 Cookie 标记为安全。 | | `VOCAT_SECURE_COOKIES` | `false` | 在使用 HTTPS 时将会话 Cookie 标记为安全。 |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | 优雅关闭超时时间。 | | `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | 优雅关闭超时时间。 |
@@ -160,6 +190,9 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自更新器使用的受信任 GitHub 仓库,格式为 `owner/name`。 | | `VOCAT_REPO` | `MengMengCode/VoCat` | 自更新器使用的受信任 GitHub 仓库,格式为 `owner/name`。 |
| `GITHUB_TOKEN` | 空 | 可选的 GitHub token,用于私有仓库或更高的 API 限额。 | | `GITHUB_TOKEN` | 空 | 可选的 GitHub token,用于私有仓库或更高的 API 限额。 |
管理员账号和密码只保存在 SQLite 数据库中。空数据库需要执行一次
`vocat bootstrap-admin` 完成初始化;环境变量和 JSON 配置都不能设置或覆盖管理员凭据。
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。 请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
## Telegram 机器人 ## Telegram 机器人
@@ -172,13 +205,9 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
/switch <设备> <iccid> /switch <设备> <iccid>
/wfc <设备> <status|on|off|reconnect> /wfc <设备> <status|on|off|reconnect>
/sms <设备> <号码> <内容> /sms <设备> <号码> <内容>
/call <设备> <号码> <秒数>
/calls <设备>
/answer <设备>
/hangup <设备>
``` ```
配置文件切换短信提交与拨号使用一次性确认按钮。定时拨号会执行模组拨号动作,并在 1–600 秒后自动挂断;不会捕获或处理通话音频。机器人不暴露 eSIM 下载、删除或重命名命令。 配置文件切换短信提交使用一次性确认按钮。机器人不暴露 eSIM 下载、删除或重命名命令。
## 更新 ## 更新
+325
View File
@@ -0,0 +1,325 @@
<p align="center">
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
</p>
<h1 align="center">VoCat</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
</p>
<p align="center">
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
</p>
[English](../README.md) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | **繁體中文** | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
Vocat 是一款面向 Quectel EC20/EC25 系列行動通訊模組的開源 Web 控制面板與工程工具套件。它在單一自包含的服務中整合了模組探索、即時射頻狀態、AT 與 USSD 終端、簡訊、WiFi Calling(WiFi 通話)、eSIM 管理、網路選擇、代理路由、通知、稽核日誌以及發佈自動化。
後端使用 Go 撰寫,介面採用 React 與 TypeScript 建構,生產環境前端被嵌入進 Go 二進位檔中。單一可執行檔即包含完整的 Web 應用,並使用 SQLite 進行持久化儲存。
<p align="center">
<img src="../img/image.png">
<img src="../img/image-1.png">
</p>
## 功能
| 領域 | Vocat 提供的能力 |
| --- | --- |
| 裝置管理 | 自動序列埠/USB 探索、多模組支援、裝置友善名稱、概覽即時更新、模組重新啟動、飛航模式以及 USB 網路卡模式控制。 |
| 射頻與網路 | 註冊狀態、電信業者、訊號指標、RSRP/RSRQ/SINR、網路模式、頻段、通道、電信業者掃描以及自動/手動選網。 |
| AT 與 USSD | 互動式 AT 終端、指令歷史、原始模組回應、USSD 發起/繼續/取消流程以及清晰的模組錯誤回報。 |
| 簡訊 | 行動通訊與 IMS 簡訊直接傳送、接收同步、長簡訊合併、送達報告、對話歷史、未讀狀態、時間戳以及逐則訊息的送達狀態。 |
| WiFi Calling | IKEv2/ePDG 隧道建立、EAP-AKA 驗證、IMS 註冊、IMS 簡訊、重新連線控制、狀態診斷以及依裝置路由。 |
| eSIM 與 eUICC | eUICC 探索、EID 與生產資訊、憑證中繼資料、多 eUICC 清單、已安裝設定檔列表、啟用/停用/切換操作,以及在卡片支援時進行下載、重新命名與刪除。 |
| 卡片策略 | 基於 ICCID 的 WiFi Calling 與飛航模式行為,策略即時套用。 |
| 代理路由 | 上游 SOCKS 路由、裝置綁定、國家規則、TCP 可達性檢查以及面向 WiFi Calling 資料路徑的 UDP Associate 檢查。 |
| 通知 | 透過 Telegram、Bark、電子郵件、Pushplus 以及簽章 Webhook 轉發新接收簡訊,每則簡訊個別推送。 |
| Telegram 機器人 | 裝置狀態、已安裝設定檔列表與切換、WiFi Calling 控制以及簡訊傳送。敏感操作需要管理員確認。 |
| 維運 | 驗證、CSRF 防護、存取策略、稽核事件、即時日誌、日誌保留、健康檢查、響應式版面、深色模式以及中英文應用介面。 |
| 發佈 | 靜態 Linux 二進位檔、systemd 安裝腳本、具 SHA-256 校驗的自我更新、Docker 映像、GHCR 發佈以及 GitHub Actions 發佈建置。 |
## 支援的硬體
Vocat 面向基於高通晶片、並暴露相容 AT、QMI、序列埠與 USB 網路介面的 Quectel 模組,包括:
- Quectel EC20
- Quectel EC25
- Quectel EG25 系列
- 相容的 EG600 及相關模組
可用功能取決於模組韌體、USB 複合裝置配置、SIM/eSIM 能力、主機驅動、無線網路以及電信業者配置。
## 安裝
### Linux 一鍵安裝
已是 root(包括預設沒有 `sudo` 的 OpenWrt/Kwrt):
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
```
一般 Linux 使用者且系統裝有 sudo
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
```
只檢查 VoWiFi/XFRM 環境,不安裝 VoCat
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
```
安裝指定版本:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
sudo bash install.sh 0.0.2
```
VoWiFi IMS 必須使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安裝腳本會從目前韌體自己的軟體源嘗試安裝嚴格匹配的 `ip-full``kmod-ipsec``kmod-ipsec4/6``kmod-crypto-authenc`、AES-CBC 和 SHA1 元件。若軟體源沒有與目前核心匹配的模組,必須更換包含這些元件的韌體,禁止強裝其他核心版本的 kmod。
安裝程式會:
- 偵測 `amd64``386``arm64``aarch64``armv7` 架構;
- 下載對應的 GitHub Release 二進位檔;
- 對照 `SHA256SUMS` 進行校驗;
- 將 Vocat 安裝到 `/opt/vocat`;
- 建立具有 Vocat 所需硬體與網路存取權限的強化版 systemd 服務;
- 將執行時配置存放在 `/etc/vocat/env`;
- 首次安裝時產生隨機初始管理員密碼。
安裝完成後開啟:
```text
http://<伺服器位址>:7575
```
### 手動二進位安裝
從 GitHub Releases 下載對應的二進位檔與 `SHA256SUMS`:
| 平台 | 發佈檔案 |
| --- | --- |
| Linux x86-64 | `vocat-linux-amd64` |
| Linux x86 32 位元 | `vocat-linux-386` |
| Linux ARM64 | `vocat-linux-arm64` |
| Linux AArch64 | `vocat-linux-aarch64` |
| Linux ARMv7 | `vocat-linux-armv7` |
校驗並安裝:
```bash
sha256sum -c SHA256SUMS --ignore-missing
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
read -rsp "管理員密碼: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
sudo env \
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
/opt/vocat/bin/vocat serve
```
該手動指令會在前台執行 Vocat。請使用 `vocat serve` 以直接啟動伺服器;在 TTY 下以 root 執行無參數的 `vocat` 會進入互動式管理選單。如需託管的 systemd 服務與自動重新啟動,請使用一鍵安裝腳本。
### Docker
如果 Linux 主機需要探索每一個接入的受支援 Quectel 模組,並持續感知 USB 熱插拔事件,請以硬體存取模式執行 Vocat:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
read -rsp "管理員密碼: " VOCAT_BOOTSTRAP_PASSWORD; echo
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
--entrypoint /opt/vocat/bin/vocat \
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
unset VOCAT_BOOTSTRAP_PASSWORD
docker run -d \
--name vocat \
--restart unless-stopped \
--network host \
--privileged \
--user 0:0 \
-v vocat-data:/opt/vocat/data \
-v /dev:/dev \
-v /sys:/sys:ro \
ghcr.io/mengmengcode/vocat:latest
```
容器啟動後開啟 `http://<伺服器位址>:7575`。主機網路是必需的,這樣 QMI 網路介面才能對 Vocat 可見;而特權裝置存取是序列埠、QMI 控制節點、TUN 介面、網路配置以及容器啟動後新增裝置所必需的。`/dev` 掛載使新的 `ttyUSB*``ttyACM*``cdc-wdm*` 節點無需重建容器即可見。
該模式有意賦予 Vocat 對主機裝置與網路堆疊的廣泛存取權限,僅在受信任的 Linux 主機上使用。自動探索目前僅識別受支援的 Quectel USB 模組(USB 廠商 ID `2c7c`),不識別任意品牌的模組。僅用 `--device` 映射單一節點(例如 `/dev/ttyUSB2``/dev/cdc-wdm0`)會將容器限定在這些固定節點上,無法提供完整的多裝置或熱插拔探索。
GHCR 映像發佈為 `linux/amd64``linux/arm64`
## 配置
Vocat 先從 `VOCAT_CONFIG` 讀取可選的 JSON 配置檔,再套用 `VOCAT_*` 環境變數。環境變數優先級更高。
| 環境變數 | 預設值 | 說明 |
| --- | --- | --- |
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP 監聽位址。 |
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite 資料庫路徑。 |
| `VOCAT_SESSION_TTL` | `24h` | 驗證工作階段有效期。 |
| `VOCAT_SECURE_COOKIES` | `false` | 在使用 HTTPS 時將工作階段 Cookie 標記為安全。 |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | 優雅關閉逾時時間。 |
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | API 請求主體最大位元組數。 |
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自我更新器使用的受信任 GitHub 倉庫,格式為 `owner/name`。 |
| `GITHUB_TOKEN` | 空 | 可選的 GitHub token,用於私有倉庫或更高的 API 限額。 |
請勿將 Telegram token、SMTP 密碼、Webhook 金鑰、SIM 憑證或其他私密資料存放在倉庫中。請透過應用設定或受保護的環境檔來配置它們。
## Telegram 機器人
啟用 Telegram 通知並配置好 Chat ID 與 Admin ID 後,機器人支援:
```text
/status [裝置]
/esim <裝置>
/switch <裝置> <iccid>
/wfc <裝置> <status|on|off|reconnect>
/sms <裝置> <號碼> <內容>
```
設定檔切換與簡訊提交使用一次性確認按鈕。機器人不暴露 eSIM 下載、刪除或重新命名命令。
## 更新
檢查是否有更新的 GitHub Release:
```bash
vocat update --check --repo MengMengCode/VoCat
```
安裝最新發佈版:
```bash
sudo vocat update --repo MengMengCode/VoCat
```
更新器會下載與目前 Linux 架構匹配的二進位檔,使用已發佈的 `SHA256SUMS` 進行校驗,原子性地替換可執行檔,並在可用時重新啟動 `vocat` systemd 服務。
Docker 安裝的更新方式:
```bash
docker pull ghcr.io/mengmengcode/vocat:latest
```
拉取新映像後重建容器。
## 開發
依賴要求:
- Go 1.25 或更新版本
- Node.js 20 或更新版本
- npm
執行前端開發伺服器:
```bash
cd web
npm install
npm run dev
```
建構嵌入的前端並啟動後端:
```bash
cd web
npm run build
cd ..
go run ./cmd/vocat
```
執行全部測試:
```bash
go test ./...
```
建構生產二進位檔:
```bash
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
```
## 發佈自動化
推送版本標籤會觸發兩個 GitHub Actions 工作流程:
- `release-binaries` 建構並發佈 `amd64``386``arm64``aarch64``armv7` 二進位檔及 `SHA256SUMS`
- `docker` 建構並向 GitHub Container Registry 發佈多架構映像。
```bash
git tag v0.2.0
git push origin v0.2.0
```
## 專案結構
```text
cmd/vocat/ 應用入口與 CLI
internal/device/ 模組探索與裝置控制
internal/modem/ AT 工作階段與回應處理
internal/server/ HTTP API、通知與內嵌 Web 伺服器
internal/store/ SQLite 持久化
internal/update/ GitHub Release 自我更新器
internal/vowifi/ IKE、EAP-AKA、IMS 與 WiFi Calling 執行時
scripts/install.sh Linux 安裝與更新腳本
web/src/ React 與 TypeScript 前端
.github/workflows/ 二進位檔與 Docker 發佈自動化
```
## 合規使用
行動通訊模組與 eSIM 操作可能影響用戶服務、已儲存的設定檔、網路註冊以及硬體狀態。請做好備份,謹慎審視破壞性操作,並僅在您被允許操作所連接的硬體與網路資源的合法環境中使用本軟體。
Vocat 不會繞過電信業者驗證、網路策略、硬體安全或 eSIM 信任要求。支援某項操作意味著 Vocat 能夠向模組或 eUICC 發起該請求;但裝置、設定檔、網路或電信業者仍可能拒絕。
## 貢獻
歡迎提交 Issue 與 Pull Request。請保持改動聚焦,在可行處附帶測試,避免提交憑證或用戶資料,並清晰地說明硬體相關行為。
提交改動前:
```bash
go test ./...
cd web && npm run build
```
## 致謝
- [Nodeseek.com](https://www.nodeseek.com) — 專注伺服器的社群
- [Linux.do](https://linux.do) — 富有啟發的技術社群
- [iniwex5](https://github.com/iniwex5) — 風格與功能指南
## 請我喝杯咖啡
| 網路 | 位址 |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## 授權條款
參見 [LICENSE](../LICENSE)。
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
@@ -0,0 +1,383 @@
# 企业微信消息推送实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
**技术栈:** Go 1.25、标准库 `net/http``encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
---
## 文件结构
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
- 修改:`web/src/types.ts`,扩展通知设置类型。
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
### 任务 1:企业微信模板与响应判定
**文件:**
- 创建:`internal/server/wecom_notification_test.go`
- 创建:`internal/server/wecom_notification.go`
- [ ] **步骤 1:编写失败的模板与响应测试**
```go
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
payload, err := renderWecomPayload(
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
)
if err != nil { t.Fatal(err) }
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
t.Fatalf("payload = %s", got)
}
}
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
t.Fatalf("template %q was accepted", template)
}
}
}
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
t.Fatalf("error = %v", err)
}
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:FAIL,提示 `renderWecomPayload``wecomTemplateValues``validateWecomResponse` 未定义。
- [ ] **步骤 3:实现最少的模板与响应代码**
`internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{``}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
```go
type wecomTemplateValues map[string]string
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
for _, name := range wecomTemplateVariableNames {
encoded, _ := json.Marshal(values[name])
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
}
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
return nil, errors.New("wecom.payload_template contains an unsupported variable")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
}
return []byte(template), nil
}
func validateWecomResponse(status int, body []byte) error {
var result struct { ErrCode int `json:"errcode"` }
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
}
return nil
}
func wecomTestValues(now time.Time) wecomTemplateValues {
return wecomTemplateValues{
"event": "test", "title": "vocat", "message": "vocat notification test",
"timestamp": now.UTC().Format(time.RFC3339),
}
}
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
if err != nil { return err }
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil { return err }
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil { return err }
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil { return fmt.Errorf("create WeCom notification request: %w", err) }
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
response, err := client.Do(request)
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
}
return nil
}
```
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:PASS。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
### 任务 2:设置 API 与敏感 Webhook URL
**文件:**
- 修改:`internal/server/settings_api_test.go`
- 修改:`internal/store/settings.go`
- 修改:`internal/server/settings_api.go`
- [ ] **步骤 1:编写失败的 API 测试**
```go
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
}
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
预期:FAIL,设置 API 返回 `invalid_notification_channel`
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
`notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings``payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig``handleNotificationTest` 和发送分支中支持 `wecom`
```go
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
case "wecom":
return []string{"urls"}
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
```
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
```go
case "wecom_template":
var template string
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
return fmt.Errorf("%s must be a template string", field)
}
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
return err
case "wecom":
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
return errors.New("wecom.urls and wecom.payload_template are required")
}
```
测试消息的变量必须为 `event: "test"``title: "vocat"``message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
预期:PASSGET/PUT 响应不会泄露 `key`,但数据库保留原 URL。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/settings_api.go internal/server/settings_api_test.go internal/store/settings.go && git commit -m "feat: configure WeCom notifications"`
预期:创建设置 API 与敏感配置提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 3:接入短信与自动任务分发
**文件:**
- 修改:`internal/server/sms_notifications_test.go`
- 修改:`internal/server/sms_notifications.go`
- 修改:`internal/server/automatic_task_notifications.go`
- [ ] **步骤 1:编写失败的事件变量测试**
```go
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
message := smsNotification{DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", Number: "+447386", Time: time.Unix(1700000000, 0), Content: "hello"}
values := wecomSMSValues(message)
if values["event"] != "sms.received" || values["content"] != "hello" || values["device_label"] != "EC20" {
t.Fatalf("values = %#v", values)
}
}
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
values := wecomAutomaticTaskValues(automaticTaskNotification{Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1700000000, 0)})
if values["event"] != "automatic_task.completed" || values["message"] != "任务已完成" || values["number"] != "" {
t.Fatalf("values = %#v", values)
}
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues' -count=1`
预期:FAIL,两个事件变量构建函数未定义。
- [ ] **步骤 3:实现分发接入**
在企业微信模块中实现 `wecomSMSValues``wecomAutomaticTaskValues`,填充全部已声明变量,短信专属字段在自动任务事件中设为空字符串。然后将 `wecom` 加入以下分发列表与 switch
```go
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
```
```go
channels := []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"}
for _, channel := range channels {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { continue }
if err != nil { s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err); continue }
var config map[string]any
if err := json.Unmarshal(setting.Config, &config); err != nil { s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err); continue }
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil { s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err) }
}
case "wecom":
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
```
保持既有游标、错误限流日志和其他通道的行为不变。
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues|TestValidateSMSNotificationConfig' -count=1`
预期:PASS`validateSMSNotificationConfig` 也接受包含有效 URL 和模板的 `wecom` 配置。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/wecom_notification.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go && git commit -m "feat: dispatch WeCom notifications"`
预期:创建两类事件分发接入提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 4:企业微信配置界面
**文件:**
- 修改:`web/src/types.ts`
- 修改:`web/src/components/settings/model.ts`
- 修改:`web/src/components/settings/PushTabs.tsx`
- 修改:`web/src/pages/SettingsPage.tsx`
- [ ] **步骤 1:扩展前端类型和表单映射**
`NotificationSettings``NotifyForms` 中增加 `wecom`。新增以下表单类型和默认请求体;URL 数组保持一项一个输入行的既有 `UrlListEditor` 约定。
```ts
export interface WecomForm {
enabled: boolean;
urls: string[];
payloadTemplate: string;
}
const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": { "content": {{message}} }
}`;
```
`formsFromNotifications` 读取 `payload_template``buildNotificationsPayload` 输出 `payload_template`,测试请求则修剪并移除空 URL。
- [ ] **步骤 2:实现企业微信页签与测试请求**
`PushTabs.tsx` 增加 `WecomTab`,显示启用开关、`UrlListEditor`、JSON `Textarea` 和变量说明。URL 列表文案必须明确“每个 Webhook URL 单独一行,点击添加 URL 增加”,不得提示使用分隔符。
```tsx
<Field label={t("JSON 请求体模板")} hint={<span> JSON 使 <code>{'{{message}}'}</code></span>}>
<Textarea value={value.payloadTemplate} onChange={(event) => onChange({ payloadTemplate: event.target.value })} disabled={off} rows={12} />
</Field>
```
`SettingsPage.tsx` 增加 `testingWecom``onTestWecom`、企业微信页签与组件渲染。测试请求使用 `POST /settings/notifications/wecom/test` 和企业微信表单 payload;成功与失败消息沿用现有通知测试模式。
- [ ] **步骤 3:运行前端构建验证**
运行:`npm run build`
工作目录:`web`
预期:Vite 类型检查与生产构建均以退出码 0 完成。
- [ ] **步骤 4:提交本任务**
运行:`git add web/src/types.ts web/src/components/settings/model.ts web/src/components/settings/PushTabs.tsx web/src/pages/SettingsPage.tsx && git commit -m "feat: add WeCom notification settings"`
预期:创建企业微信设置 UI 提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 5:完整验证
**文件:**
- 修改:`internal/server/wecom_notification.go`
- 修改:`internal/server/wecom_notification_test.go`
- 修改:`internal/server/settings_api.go`
- 修改:`internal/server/settings_api_test.go`
- 修改:`internal/store/settings.go`
- 修改:`internal/server/sms_notifications.go`
- 修改:`internal/server/sms_notifications_test.go`
- 修改:`internal/server/automatic_task_notifications.go`
- 修改:`web/src/types.ts`
- 修改:`web/src/components/settings/model.ts`
- 修改:`web/src/components/settings/PushTabs.tsx`
- 修改:`web/src/pages/SettingsPage.tsx`
- [ ] **步骤 1:格式化 Go 代码**
运行:`gofmt -w internal/server/wecom_notification.go internal/server/wecom_notification_test.go internal/server/settings_api.go internal/server/settings_api_test.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go internal/store/settings.go`
预期:所有修改的 Go 文件采用项目标准格式。
- [ ] **步骤 2:运行前端生产构建**
运行:`npm run build`
工作目录:`web`
预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。
- [ ] **步骤 3:运行后端回归测试**
运行:`go test ./...`
预期:所有目标包通过,无失败测试;`cmd/vocat``web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。
- [ ] **步骤 4:检查最终变更**
运行:`git diff --check && git status --short`
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
@@ -0,0 +1,55 @@
# 企业微信消息推送设计
## 目标
新增独立的 `wecom` 通知通道,通过企业微信“消息推送(原群机器人)”Webhook 推送新收到的短信和自动任务执行结果。外部 API 契约与既有通知通道保持一致。
## 配置模型
`wecom` 配置包含:
- `enabled`:是否启用通道。
- `urls`:一个或多个企业微信消息推送 Webhook URL。Web 设置页将每个 URL
显示为独立输入行,通过“添加 URL”按钮新增输入行、通过删除按钮移除输入行;
不使用逗号、空格或换行分隔多个 URL。
- `payload_template`:完整 JSON 请求体模板。
Webhook URL 含有企业微信访问密钥,必须作为敏感配置存储、在读取接口中脱敏,并在日志和错误信息中避免泄露。URL 沿用现有出站 URL 校验与 SSRF 防护。
## 模板语义
用户在 Web 设置页编辑完整 JSON 请求体,以选择企业微信支持的任意消息格式,例如 `text``markdown``news``template_card`
模板变量仅能作为 JSON 值出现,服务端使用 JSON 编码后的字符串替换,调用方不得在变量外添加引号。示例:
```json
{
"msgtype": "text",
"text": {
"content": {{message}}
}
}
```
可用变量:
- 通用:`{{event}}``{{title}}``{{message}}``{{timestamp}}`
- 短信事件:`{{content}}``{{number}}``{{device_id}}``{{device_name}}``{{device_label}}``{{time}}`
自动任务使用通用变量;短信专属变量在自动任务中替换为空字符串。模板渲染后必须为非空 JSON 对象,不得保留模板变量;无效模板在保存和测试时拒绝。
## 发送流程
短信分发器为 `wecom` 维护独立游标,发送失败不会阻塞其他通知渠道。自动任务完成后,和 Telegram、Bark、邮件、PushPlus、通用 Webhook 一样,向已启用的 `wecom` 通道发送结果。
发送器逐一 POST 渲染后的 JSON 到所有配置 URL,使用现有受限 HTTP 客户端。除 HTTP 2xx 外,企业微信返回 JSON 的 `errcode` 非零也视为服务商拒绝。
## Web 与 API
设置 API 将 `wecom` 加入已知通道和配置字段白名单,并提供 `POST /api/settings/notifications/wecom/test`。Web 设置页新增“企业微信”页签、启用开关、逐行编辑的 Webhook URL 列表、JSON 模板编辑器和测试按钮。
默认模板使用 `text` 消息,发送一条可辨识的测试内容。
## 验证
后端测试覆盖:配置字段验证、模板的 JSON 转义和拒绝无效模板、企业微信请求载荷、非零 `errcode` 失败处理、通知设置 API 读写与敏感 Webhook URL 保留。前端构建用于验证新增表单与类型契约。
+6 -2
View File
@@ -3,10 +3,12 @@ module vocat
go 1.25.0 go 1.25.0
require ( require (
github.com/coder/websocket v1.8.15
github.com/iniwex5/quectel-qmi-go v0.6.0
go.bug.st/serial v1.6.4 go.bug.st/serial v1.6.4
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.52.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
golang.org/x/term v0.34.0 golang.org/x/term v0.43.0
modernc.org/sqlite v1.38.2 modernc.org/sqlite v1.38.2
) )
@@ -17,6 +19,8 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/warthog618/sms v0.3.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
modernc.org/libc v1.66.3 // indirect modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+22 -6
View File
@@ -1,5 +1,8 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -8,20 +11,29 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
@@ -31,10 +43,14 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+80 -10
View File
@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
@@ -20,8 +21,13 @@ var (
ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidCredentials = errors.New("invalid credentials")
ErrUnauthorized = errors.New("unauthorized") ErrUnauthorized = errors.New("unauthorized")
ErrInvalidCSRF = errors.New("invalid csrf token") ErrInvalidCSRF = errors.New("invalid csrf token")
ErrEmptyPassword = errors.New("password cannot be empty")
) )
const bcryptPasswordLimit = 72
var longPasswordHashPrefix = []byte("$vocat-sha256$")
type Options struct { type Options struct {
SessionTTL time.Duration SessionTTL time.Duration
BcryptCost int BcryptCost int
@@ -85,14 +91,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
current, err := s.store.CurrentAdmin(ctx) current, err := s.store.CurrentAdmin(ctx)
if err == nil && if err == nil &&
current.Username == username && current.Username == username &&
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil { comparePassword(current.PasswordHash, password) == nil {
return nil return nil
} }
if err != nil && !errors.Is(err, store.ErrNotFound) { if err != nil && !errors.Is(err, store.ErrNotFound) {
return fmt.Errorf("auth: read configured admin: %w", err) return fmt.Errorf("auth: read configured admin: %w", err)
} }
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost) passwordHash, err := hashPassword(password, s.bcryptCost)
if err != nil { if err != nil {
return fmt.Errorf("auth: hash admin password: %w", err) return fmt.Errorf("auth: hash admin password: %w", err)
} }
@@ -102,16 +108,50 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
return nil return nil
} }
// EnsureAdminIfMissing initializes the administrator only for a new database.
// Once an administrator exists, the database is the sole credential source;
// process configuration must never overwrite a password changed through the UI
// or CLI on a later restart.
func (s *Service) EnsureAdminIfMissing(ctx context.Context, username string, password string) (bool, error) {
if _, err := s.store.CurrentAdmin(ctx); err == nil {
return false, nil
} else if !errors.Is(err, store.ErrNotFound) {
return false, fmt.Errorf("auth: read configured admin: %w", err)
}
if err := s.EnsureAdmin(ctx, username, password); err != nil {
return false, err
}
return true, nil
}
// ResetAdminCredentials replaces the single administrator without requiring
// the previous credentials. It is intended for trusted local recovery flows
// such as the root-only management CLI. Store.SetAdmin atomically revokes all
// existing sessions when the credentials change.
func (s *Service) ResetAdminCredentials(ctx context.Context, username string, password string) error {
username = strings.TrimSpace(username)
if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") {
return errors.New("administrator username must contain between 1 and 64 characters without control whitespace")
}
if password == "" {
return ErrEmptyPassword
}
if err := s.EnsureAdmin(ctx, username, password); err != nil {
return fmt.Errorf("auth: reset administrator credentials: %w", err)
}
return nil
}
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) { func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password)) _ = comparePassword(s.dummyHash, password)
return Credentials{}, ErrInvalidCredentials return Credentials{}, ErrInvalidCredentials
} }
if err != nil { if err != nil {
return Credentials{}, fmt.Errorf("auth: find admin: %w", err) return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
} }
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil { if comparePassword(admin.PasswordHash, password) != nil {
return Credentials{}, ErrInvalidCredentials return Credentials{}, ErrInvalidCredentials
} }
@@ -250,24 +290,24 @@ func (s *Service) ChangePassword(
currentPassword string, currentPassword string,
newPassword string, newPassword string,
) error { ) error {
if len(newPassword) < 12 || len(newPassword) > 1024 { if newPassword == "" {
return errors.New("new password must contain between 12 and 1024 characters") return ErrEmptyPassword
} }
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword)) _ = comparePassword(s.dummyHash, currentPassword)
return ErrInvalidCredentials return ErrInvalidCredentials
} }
if err != nil { if err != nil {
return fmt.Errorf("auth: find admin: %w", err) return fmt.Errorf("auth: find admin: %w", err)
} }
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil { if comparePassword(admin.PasswordHash, currentPassword) != nil {
return ErrInvalidCredentials 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") 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 { if err != nil {
return fmt.Errorf("auth: hash new password: %w", err) return fmt.Errorf("auth: hash new password: %w", err)
} }
@@ -277,6 +317,36 @@ func (s *Service) ChangePassword(
return nil return nil
} }
// hashPassword keeps ordinary bcrypt hashes compatible with existing
// installations. bcrypt rejects inputs longer than 72 bytes, so only longer
// passwords use a tagged SHA-256 pre-hash before bcrypt.
func hashPassword(password string, cost int) ([]byte, error) {
material := []byte(password)
longPassword := len(material) > bcryptPasswordLimit
if longPassword {
digest := sha256.Sum256(material)
material = digest[:]
}
passwordHash, err := bcrypt.GenerateFromPassword(material, cost)
if err != nil {
return nil, err
}
if !longPassword {
return passwordHash, nil
}
return append(append([]byte(nil), longPasswordHashPrefix...), passwordHash...), nil
}
func comparePassword(passwordHash []byte, password string) error {
material := []byte(password)
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
digest := sha256.Sum256(material)
material = digest[:]
passwordHash = passwordHash[len(longPasswordHashPrefix):]
}
return bcrypt.CompareHashAndPassword(passwordHash, material)
}
func randomToken() (string, error) { func randomToken() (string, error) {
buffer := make([]byte, 32) buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil { if _, err := rand.Read(buffer); err != nil {
+89
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -96,3 +97,91 @@ func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) {
t.Fatalf("login with new password: %v", err) t.Fatalf("login with new password: %v", err)
} }
} }
func TestResetAdminCredentialsChangesUsernameAndPasswordWithoutOldPassword(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
credentials, err := service.Login(ctx, "admin", "correct-password")
if err != nil {
t.Fatal(err)
}
if err := service.ResetAdminCredentials(ctx, "new-admin", "replacement-password"); err != nil {
t.Fatalf("ResetAdminCredentials() error = %v", err)
}
if _, err := service.Login(ctx, "admin", "correct-password"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("old credentials error = %v, want ErrInvalidCredentials", err)
}
if _, err := service.Login(ctx, "new-admin", "replacement-password"); err != nil {
t.Fatalf("new credentials login error = %v", err)
}
if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("old session error = %v, want ErrUnauthorized", err)
}
}
func TestResetAdminCredentialsValidatesInput(t *testing.T) {
service := newTestService(t)
for _, test := range []struct {
name string
username string
password string
}{
{name: "empty username", password: "replacement-password"},
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
{name: "empty password", username: "admin", password: ""},
} {
t.Run(test.name, func(t *testing.T) {
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
t.Fatal("ResetAdminCredentials() accepted invalid input")
}
})
}
}
func TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
ctx := context.Background()
for _, password := range []string{"1", strings.Repeat("x", 256)} {
service := newTestService(t)
if err := service.ResetAdminCredentials(ctx, "admin", password); err != nil {
t.Fatalf("ResetAdminCredentials(%d-byte password) error = %v", len(password), err)
}
if _, err := service.Login(ctx, "admin", password); err != nil {
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
}
}
}
func TestChangePasswordAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
ctx := context.Background()
for _, password := range []string{"1", strings.Repeat("long-password-", 32)} {
service := newTestService(t)
if err := service.ChangePassword(ctx, "admin", "correct-password", password); err != nil {
t.Fatalf("ChangePassword(%d-byte password) error = %v", len(password), err)
}
if _, err := service.Login(ctx, "admin", password); err != nil {
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
}
}
}
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
ctx := context.Background()
service := newTestService(t)
if err := service.ChangePassword(ctx, "admin", "correct-password", "changed-password"); err != nil {
t.Fatal(err)
}
created, err := service.EnsureAdminIfMissing(ctx, "admin", "stale-config-password")
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("existing administrator was reported as newly created")
}
if _, err := service.Login(ctx, "admin", "changed-password"); err != nil {
t.Fatalf("database password was overwritten: %v", err)
}
if _, err := service.Login(ctx, "admin", "stale-config-password"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("stale configured password became active: %v", err)
}
}
+9 -35
View File
@@ -19,8 +19,6 @@ const maxConfigBytes = 1 << 20
type Config struct { type Config struct {
Address string Address string
DatabasePath string DatabasePath string
AdminUsername string
AdminPassword string
SessionTTL time.Duration SessionTTL time.Duration
SecureCookies bool SecureCookies bool
ShutdownTimeout time.Duration ShutdownTimeout time.Duration
@@ -28,25 +26,25 @@ type Config struct {
} }
type fileConfig struct { type fileConfig struct {
Address *string `json:"address"` Address *string `json:"address"`
DatabasePath *string `json:"database_path"` DatabasePath *string `json:"database_path"`
AdminUsername *string `json:"admin_username"` // Retain the legacy keys only so upgrades do not reject an existing config
AdminPassword *string `json:"admin_password"` // file. They are deliberately ignored: administrator credentials are read
// exclusively from SQLite.
LegacyAdminUsername *string `json:"admin_username"`
LegacyAdminPassword *string `json:"admin_password"`
SessionTTL *string `json:"session_ttl"` SessionTTL *string `json:"session_ttl"`
SecureCookies *bool `json:"secure_cookies"` SecureCookies *bool `json:"secure_cookies"`
ShutdownTimeout *string `json:"shutdown_timeout"` ShutdownTimeout *string `json:"shutdown_timeout"`
MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"` MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"`
} }
// Default returns a configuration suitable for a first local deployment. // Default returns the non-secret process configuration. Administrator
// Operators should replace the bootstrap password through // credentials are initialized separately and stored only in SQLite.
// VOCAT_ADMIN_PASSWORD before exposing the service.
func Default() Config { func Default() Config {
return Config{ return Config{
Address: "0.0.0.0:7575", Address: "0.0.0.0:7575",
DatabasePath: "./data/vocat.db", DatabasePath: "./data/vocat.db",
AdminUsername: "admin",
AdminPassword: "admin",
SessionTTL: 24 * time.Hour, SessionTTL: 24 * time.Hour,
SecureCookies: false, SecureCookies: false,
ShutdownTimeout: 10 * time.Second, ShutdownTimeout: 10 * time.Second,
@@ -116,12 +114,6 @@ func applyFile(cfg *Config, values fileConfig) error {
if values.DatabasePath != nil { if values.DatabasePath != nil {
cfg.DatabasePath = *values.DatabasePath cfg.DatabasePath = *values.DatabasePath
} }
if values.AdminUsername != nil {
cfg.AdminUsername = *values.AdminUsername
}
if values.AdminPassword != nil {
cfg.AdminPassword = *values.AdminPassword
}
if values.SessionTTL != nil { if values.SessionTTL != nil {
duration, err := time.ParseDuration(*values.SessionTTL) duration, err := time.ParseDuration(*values.SessionTTL)
if err != nil { if err != nil {
@@ -154,8 +146,6 @@ func applyEnvironment(cfg *Config) error {
applyString("VOCAT_ADDR", &cfg.Address) applyString("VOCAT_ADDR", &cfg.Address)
applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath) applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath)
applyString("VOCAT_ADMIN_USERNAME", &cfg.AdminUsername)
applyString("VOCAT_ADMIN_PASSWORD", &cfg.AdminPassword)
if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok { if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok {
duration, err := time.ParseDuration(value) duration, err := time.ParseDuration(value)
@@ -203,16 +193,6 @@ func (cfg Config) Validate() error {
if strings.TrimSpace(cfg.DatabasePath) == "" { if strings.TrimSpace(cfg.DatabasePath) == "" {
return errors.New("database_path must not be empty") return errors.New("database_path must not be empty")
} }
username := strings.TrimSpace(cfg.AdminUsername)
if username == "" || len(username) > 64 {
return errors.New("admin_username must contain between 1 and 64 characters")
}
if strings.ContainsAny(username, "\r\n\t") {
return errors.New("admin_username must not contain control whitespace")
}
if cfg.AdminPassword == "" {
return errors.New("admin_password must not be empty")
}
if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour { if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour {
return errors.New("session_ttl must be between 5m and 720h") return errors.New("session_ttl must be between 5m and 720h")
} }
@@ -224,9 +204,3 @@ func (cfg Config) Validate() error {
} }
return nil return nil
} }
// UsesDefaultCredentials reports whether the documented bootstrap credentials
// are still active.
func (cfg Config) UsesDefaultCredentials() bool {
return cfg.AdminUsername == "admin" && cfg.AdminPassword == "admin"
}
+20 -6
View File
@@ -13,6 +13,7 @@ var configEnvironment = []string{
"VOCAT_DATABASE_PATH", "VOCAT_DATABASE_PATH",
"VOCAT_ADMIN_USERNAME", "VOCAT_ADMIN_USERNAME",
"VOCAT_ADMIN_PASSWORD", "VOCAT_ADMIN_PASSWORD",
"VOCAT_ADMIN_PASSWORD_B64",
"VOCAT_SESSION_TTL", "VOCAT_SESSION_TTL",
"VOCAT_SECURE_COOKIES", "VOCAT_SECURE_COOKIES",
"VOCAT_SHUTDOWN_TIMEOUT", "VOCAT_SHUTDOWN_TIMEOUT",
@@ -39,9 +40,6 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Address != "0.0.0.0:7575" { if cfg.Address != "0.0.0.0:7575" {
t.Fatalf("Address = %q", cfg.Address) t.Fatalf("Address = %q", cfg.Address)
} }
if !cfg.UsesDefaultCredentials() {
t.Fatal("expected bootstrap credentials")
}
} }
func TestLoadFileThenEnvironmentOverride(t *testing.T) { func TestLoadFileThenEnvironmentOverride(t *testing.T) {
@@ -50,8 +48,6 @@ func TestLoadFileThenEnvironmentOverride(t *testing.T) {
content := []byte(`{ content := []byte(`{
"address": "127.0.0.1:8000", "address": "127.0.0.1:8000",
"database_path": "/tmp/from-file.db", "database_path": "/tmp/from-file.db",
"admin_username": "operator",
"admin_password": "from-file",
"session_ttl": "2h", "session_ttl": "2h",
"secure_cookies": false, "secure_cookies": false,
"shutdown_timeout": "12s", "shutdown_timeout": "12s",
@@ -72,7 +68,7 @@ func TestLoadFileThenEnvironmentOverride(t *testing.T) {
if cfg.Address != "0.0.0.0:9000" || !cfg.SecureCookies { if cfg.Address != "0.0.0.0:9000" || !cfg.SecureCookies {
t.Fatalf("environment override not applied: %+v", cfg) t.Fatalf("environment override not applied: %+v", cfg)
} }
if cfg.AdminUsername != "operator" || cfg.SessionTTL != 2*time.Hour { if cfg.SessionTTL != 2*time.Hour {
t.Fatalf("file values not applied: %+v", cfg) t.Fatalf("file values not applied: %+v", cfg)
} }
} }
@@ -90,6 +86,24 @@ func TestLoadRejectsUnknownJSONField(t *testing.T) {
} }
} }
func TestLoadIgnoresLegacyAdministratorConfiguration(t *testing.T) {
clearConfigEnvironment(t)
path := filepath.Join(t.TempDir(), "vocat.json")
if err := os.WriteFile(path, []byte(`{
"admin_username": "legacy-admin",
"admin_password": "legacy-password"
}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("VOCAT_CONFIG", path)
t.Setenv("VOCAT_ADMIN_USERNAME", "environment-admin")
t.Setenv("VOCAT_ADMIN_PASSWORD", "environment-password")
if _, err := Load(); err != nil {
t.Fatalf("Load() rejected ignored legacy credentials: %v", err)
}
}
func TestLoadRejectsInvalidEnvironment(t *testing.T) { func TestLoadRejectsInvalidEnvironment(t *testing.T) {
clearConfigEnvironment(t) clearConfigEnvironment(t)
t.Setenv("VOCAT_SESSION_TTL", "tomorrow") t.Setenv("VOCAT_SESSION_TTL", "tomorrow")
+146
View File
@@ -0,0 +1,146 @@
package developer
import (
"context"
"encoding/json"
"errors"
"fmt"
"vocat/internal/exportproxy"
"vocat/internal/httpsmode"
"vocat/internal/store"
)
func Enabled(ctx context.Context, database *store.Store) bool {
setting, err := database.AppSetting(ctx, EnabledSettingKey)
if err != nil {
return false
}
var document struct {
Enabled bool `json:"enabled"`
}
return json.Unmarshal(setting.Value, &document) == nil && document.Enabled
}
const (
EnabledSettingKey = "developer.enabled"
DeviceLimitSettingKey = "developer.device_limit"
SMSHourlyLimitKey = "developer.sms_hourly_limit"
DefaultDeviceLimit = 5
MaxDeviceLimit = 10
DefaultSMSHourlyLimit = 10
MaxSMSHourlyLimit = 20
)
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
if !enabled {
return DefaultDeviceLimit
}
setting, err := database.AppSetting(ctx, DeviceLimitSettingKey)
if err != nil {
return DefaultDeviceLimit
}
var document struct {
Limit int `json:"limit"`
}
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 {
return DefaultDeviceLimit
}
if document.Limit > MaxDeviceLimit {
return MaxDeviceLimit
}
return document.Limit
}
func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error {
if limit < 1 || limit > MaxDeviceLimit {
return fmt.Errorf("device limit must be between 1 and %d", MaxDeviceLimit)
}
value, err := json.Marshal(map[string]int{"limit": limit})
if err != nil {
return err
}
return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value})
}
// SMSHourlyLimit is enforced regardless of developer mode. Developer mode
// only controls whether administrators can see and modify this value.
func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
setting, err := database.AppSetting(ctx, SMSHourlyLimitKey)
if err != nil {
return DefaultSMSHourlyLimit
}
var document struct {
Limit int `json:"limit"`
}
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 {
return DefaultSMSHourlyLimit
}
if document.Limit > MaxSMSHourlyLimit {
return MaxSMSHourlyLimit
}
return document.Limit
}
func SetSMSHourlyLimit(ctx context.Context, database *store.Store, limit int) error {
if limit < 1 || limit > MaxSMSHourlyLimit {
return fmt.Errorf("SMS hourly limit must be between 1 and %d", MaxSMSHourlyLimit)
}
value, err := json.Marshal(map[string]int{"limit": limit})
if err != nil {
return err
}
return database.UpsertAppSetting(ctx, store.AppSetting{Key: SMSHourlyLimitKey, Value: value})
}
// ResetExperimental restores every mutable developer-only setting. It is
// called both by `vocat develop off` and at startup whenever developer mode is
// disabled, so stale database values cannot silently remain active.
func ResetExperimental(ctx context.Context, database *store.Store) error {
httpsValue, err := json.Marshal(map[string]bool{"enabled": false})
if err != nil {
return err
}
var resetErrors []error
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: httpsValue}); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset self-signed HTTPS: %w", err))
}
if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err))
}
if err := SetSMSHourlyLimit(ctx, database, DefaultSMSHourlyLimit); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset SMS hourly limit: %w", err))
}
if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) {
resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err))
}
devices, err := database.ListDevices(ctx)
if err != nil {
resetErrors = append(resetErrors, fmt.Errorf("list devices while disabling roaming data: %w", err))
} else {
for _, device := range devices {
if !device.NetworkEnabled {
continue
}
device.NetworkEnabled = false
if err := database.UpsertDevice(ctx, device); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("disable roaming data for device %s: %w", device.ID, err))
}
}
}
policies, err := database.ListCardPolicies(ctx)
if err != nil {
resetErrors = append(resetErrors, fmt.Errorf("list card policies while disabling roaming data: %w", err))
} else {
for _, policy := range policies {
if !policy.NetworkEnabled {
continue
}
policy.NetworkEnabled = false
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("disable roaming policy for card %s: %w", policy.ICCID, err))
}
}
}
return errors.Join(resetErrors...)
}
+125
View File
@@ -0,0 +1,125 @@
package developer
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"vocat/internal/exportproxy"
"vocat/internal/httpsmode"
"vocat/internal/store"
)
func TestResetExperimentalRestoresDefaults(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if err := SetDeviceLimit(ctx, database, 8); err != nil {
t.Fatal(err)
}
if err := SetSMSHourlyLimit(ctx, database, 18); err != nil {
t.Fatal(err)
}
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: enabled}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", NetworkEnabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: "8901000000000000001", NetworkEnabled: true, IPVersion: "IPV4V6"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: exportproxy.SettingKey, Value: json.RawMessage(`[]`)}); err != nil {
t.Fatal(err)
}
if err := ResetExperimental(ctx, database); err != nil {
t.Fatal(err)
}
if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit {
t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit)
}
if limit := SMSHourlyLimit(ctx, database); limit != DefaultSMSHourlyLimit {
t.Fatalf("SMS hourly limit = %d, want %d", limit, DefaultSMSHourlyLimit)
}
setting, err := database.AppSetting(ctx, httpsmode.SettingKey)
if err != nil {
t.Fatal(err)
}
var document struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(setting.Value, &document); err != nil || document.Enabled {
t.Fatalf("HTTPS setting = %s, error = %v", setting.Value, err)
}
device, err := database.Device(ctx, "modem-1")
if err != nil || device.NetworkEnabled {
t.Fatalf("device roaming data was not disabled: %+v, %v", device, err)
}
policy, err := database.CardPolicy(ctx, "8901000000000000001")
if err != nil || policy.NetworkEnabled {
t.Fatalf("card roaming policy was not disabled: %+v, %v", policy, err)
}
if _, err := database.AppSetting(ctx, exportproxy.SettingKey); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("export proxy configurations were not deleted: %v", err)
}
}
func TestSetDeviceLimitValidatesRange(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if SetDeviceLimit(ctx, database, 0) == nil || SetDeviceLimit(ctx, database, MaxDeviceLimit+1) == nil {
t.Fatal("out-of-range device limit was accepted")
}
}
func TestSetSMSHourlyLimitValidatesRange(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
t.Fatal("out-of-range SMS hourly limit was accepted")
}
if err := SetSMSHourlyLimit(ctx, database, 15); err != nil {
t.Fatal(err)
}
if got := SMSHourlyLimit(ctx, database); got != 15 {
t.Fatalf("SMS hourly limit = %d, want 15", got)
}
}
func TestStoredLimitsAboveHardMaximumAreClamped(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
for key, limit := range map[string]int{
DeviceLimitSettingKey: 99,
SMSHourlyLimitKey: 99,
} {
value, _ := json.Marshal(map[string]int{"limit": limit})
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: key, Value: value}); err != nil {
t.Fatal(err)
}
}
if got := DeviceLimit(ctx, database, true); got != MaxDeviceLimit {
t.Fatalf("device limit = %d, want %d", got, MaxDeviceLimit)
}
if got := SMSHourlyLimit(ctx, database); got != MaxSMSHourlyLimit {
t.Fatalf("SMS hourly limit = %d, want %d", got, MaxSMSHourlyLimit)
}
}
+238
View File
@@ -0,0 +1,238 @@
package device
import (
_ "embed"
"encoding/json"
"sort"
"strings"
)
// The offline table is generated by scripts/update-carriers.py from Android's
// versioned carrier ID database, with the previous global table retained as a
// fallback for PLMNs that Android does not yet catalogue.
//
//go:embed mccmnc.json
var carrierDatabaseJSON []byte
type carrierDatabase struct {
Carriers map[string][]string `json:"c"`
Countries map[string]string `json:"i"`
Rules []carrierRule `json:"r"`
}
type carrierRule struct {
Name string `json:"n"`
PLMNs []string `json:"m"`
IMSIPatterns []string `json:"x"`
SPNs []string `json:"s"`
GID1Prefixes []string `json:"g1"`
GID2Prefixes []string `json:"g2"`
ICCIDPrefixes []string `json:"i"`
}
// CarrierIdentity contains the SIM-issued values used by Android's carrier
// resolver. MNC length comes from EF_AD; GID values come from EF_GID1/2.
type CarrierIdentity struct {
IMSI string
ICCID string
SPN string
GID1 string
GID2 string
MNCLength int
}
// CountryForMCC returns the ISO alpha-2 country/territory code associated with
// a three-digit mobile country code in the embedded Android carrier database.
func CountryForMCC(mcc string) (string, bool) {
mcc = strings.TrimSpace(mcc)
if len(mcc) != 3 {
return "", false
}
country := strings.ToUpper(strings.TrimSpace(globalCarrierDatabase.Countries[mcc]))
return country, len(country) == 2
}
// 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 {
panic("device: invalid embedded MCC/MNC database: " + err.Error())
}
return database
}()
// CarrierForPLMN returns the offline carrier display name and ISO alpha-2
// country/territory code for a numeric five- or six-digit PLMN.
func CarrierForPLMN(plmn string) (name, countryCode string, ok bool) {
plmn = strings.TrimSpace(plmn)
if !decimalDigits(plmn, 5, 6) {
return "", "", false
}
entry, ok := globalCarrierDatabase.Carriers[plmn]
if !ok || len(entry) == 0 || strings.TrimSpace(entry[0]) == "" {
return "", "", false
}
name = strings.TrimSpace(entry[0])
if len(entry) > 1 {
countryCode = strings.ToUpper(strings.TrimSpace(entry[1]))
}
return name, countryCode, true
}
// CarrierForIMSI resolves the home PLMN carried by an IMSI. MNCs may contain
// either two or three digits, so prefer an exact six-digit database match and
// then fall back to the five-digit form. This avoids treating the first three
// subscriber digits as a three-digit MNC for networks such as 234-33.
func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
imsi = strings.TrimSpace(imsi)
if !decimalDigits(imsi, 5, 20) {
return "", "", "", false
}
for _, length := range []int{6, 5} {
if len(imsi) < length {
continue
}
candidate := imsi[:length]
carrier, country, found := CarrierForPLMN(candidate)
if found {
return candidate, carrier, country, true
}
}
return "", "", "", false
}
// CarrierForSIM applies the constrained Android carrier-ID rules before the
// MCC/MNC fallback. This is important for MVNO and travel eSIM profiles where
// several customer-facing carriers authenticate through the same home PLMN.
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
imsi := strings.TrimSpace(identity.IMSI)
if !decimalDigits(imsi, 5, 20) || IsPlaceholderIMSI(imsi) {
return "", "", "", false
}
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
bestScore := -1
for _, rule := range globalCarrierDatabase.Rules {
matchedPLMN := firstMatchingValue(rule.PLMNs, func(value string) bool {
return containsString(plmns, value)
})
if matchedPLMN == "" {
continue
}
score := 1 << 8
if len(rule.IMSIPatterns) > 0 {
if firstMatchingValue(rule.IMSIPatterns, func(pattern string) bool { return imsiPatternMatch(imsi, pattern) }) == "" {
continue
}
score += 1 << 7
}
if len(rule.ICCIDPrefixes) > 0 {
if firstMatchingValue(rule.ICCIDPrefixes, func(prefix string) bool { return strings.HasPrefix(identity.ICCID, prefix) }) == "" {
continue
}
score += 1 << 6
}
if len(rule.GID1Prefixes) > 0 {
if firstMatchingValue(rule.GID1Prefixes, func(prefix string) bool { return prefixFold(identity.GID1, prefix) }) == "" {
continue
}
score += 1 << 5
}
if len(rule.GID2Prefixes) > 0 {
if firstMatchingValue(rule.GID2Prefixes, func(prefix string) bool { return prefixFold(identity.GID2, prefix) }) == "" {
continue
}
score += 1 << 4
}
if len(rule.SPNs) > 0 {
if !containsFold(rule.SPNs, identity.SPN) {
continue
}
score += 1 << 1
}
if score > bestScore {
bestScore = score
plmn = matchedPLMN
name = strings.TrimSpace(rule.Name)
}
}
if bestScore >= 0 && name != "" {
countryCode, _ = CountryForMCC(plmn[:3])
return plmn, name, countryCode, true
}
return CarrierForIMSI(imsi)
}
func carrierPLMNCandidates(imsi string, mncLength int) []string {
if (mncLength == 2 || mncLength == 3) && len(imsi) >= 3+mncLength {
return []string{imsi[:3+mncLength]}
}
result := make([]string, 0, 2)
for _, length := range []int{6, 5} {
if len(imsi) >= length {
result = append(result, imsi[:length])
}
}
return result
}
func firstMatchingValue(values []string, match func(string) bool) string {
for _, value := range values {
if match(value) {
return value
}
}
return ""
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func containsFold(values []string, wanted string) bool {
for _, value := range values {
if strings.EqualFold(value, wanted) {
return true
}
}
return false
}
func prefixFold(value, prefix string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), strings.ToLower(strings.TrimSpace(prefix)))
}
func imsiPatternMatch(imsi, pattern string) bool {
pattern = strings.TrimSpace(pattern)
if len(imsi) < len(pattern) {
return false
}
for index, value := range pattern {
if value != 'x' && value != 'X' && byte(value) != imsi[index] {
return false
}
}
return true
}
+7
View File
@@ -274,6 +274,13 @@ func (manager *Manager) SetFlight(
if err := manager.validateActive(id, state); err != nil { if err := manager.validateActive(id, state); err != nil {
return FlightResult{}, err return FlightResult{}, err
} }
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)) client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil { if err != nil {
manager.setResult(id, state, nil, err) manager.setResult(id, state, nil, err)
+200
View File
@@ -2,9 +2,209 @@ package device
import ( import (
"context" "context"
"errors"
"testing" "testing"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
"vocat/internal/modem"
) )
type fakeQMIRadioSession struct {
mode qmi.OperatingMode
getModes []qmi.OperatingMode
setModes []qmi.OperatingMode
getErr error
setErr error
closeCount int
iccid string
iccidErr error
imei string
imeiErr error
openedAIDs [][]byte
openChannel byte
openErr error
closedChannels []byte
apdus [][]byte
apduResponse []byte
apduErr error
}
func (session *fakeQMIRadioSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) {
if len(session.getModes) > 0 {
mode := session.getModes[0]
session.getModes = session.getModes[1:]
return mode, session.getErr
}
return session.mode, session.getErr
}
func (session *fakeQMIRadioSession) SetOperatingMode(_ context.Context, mode qmi.OperatingMode) error {
if session.setErr != nil {
return session.setErr
}
session.setModes = append(session.setModes, mode)
session.mode = mode
return nil
}
func (session *fakeQMIRadioSession) Close() error {
session.closeCount++
return nil
}
func (session *fakeQMIRadioSession) GetICCID(context.Context) (string, error) {
return session.iccid, session.iccidErr
}
func (session *fakeQMIRadioSession) GetIMEI(context.Context) (string, error) {
return session.imei, session.imeiErr
}
func (session *fakeQMIRadioSession) OpenLogicalChannel(_ context.Context, _ uint8, aid []byte) (byte, error) {
session.openedAIDs = append(session.openedAIDs, append([]byte(nil), aid...))
if session.openErr != nil {
return 0, session.openErr
}
if session.openChannel == 0 {
return 1, nil
}
return session.openChannel, nil
}
func (session *fakeQMIRadioSession) CloseLogicalChannel(_ context.Context, _ uint8, channel uint8) error {
session.closedChannels = append(session.closedChannels, channel)
return nil
}
func (session *fakeQMIRadioSession) SendAPDU(_ context.Context, _ uint8, _ uint8, command []byte) ([]byte, error) {
session.apdus = append(session.apdus, append([]byte(nil), command...))
return append([]byte(nil), session.apduResponse...), session.apduErr
}
func newStartedNativeQMITestManager(t *testing.T) (*Manager, *staticOpener, string) {
t.Helper()
const id = "wwan0"
opener := &staticOpener{client: &transcriptClient{}}
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
ID: id,
Product: "410 WiFi stick",
QMIControl: "/dev/wwan0qmi0",
NetworkInterface: "wwan0",
ATPort: modem.Port{
Path: "/dev/wwan0at0",
Name: "wwan0at0",
Role: modem.PortRoleAT,
},
}}},
Opener: opener,
})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
manager.mu.Lock()
manager.devices[id].snapshot = &Snapshot{
DeviceID: id,
OperatingMode: 7,
ModeKnown: true,
FlightMode: true,
RadioOff: true,
}
manager.mu.Unlock()
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
return manager, opener, id
}
func TestSetFlightUsesQMIDMSForNativeWWAN(t *testing.T) {
manager, atOpener, id := newStartedNativeQMITestManager(t)
session := &fakeQMIRadioSession{mode: qmi.ModeOffline}
var openedPath string
manager.qmiRadioOpener = func(_ context.Context, path string) (qmiRadioSession, error) {
openedPath = path
return session, nil
}
disabled, err := manager.SetFlight(context.Background(), id, false)
if err != nil {
t.Fatalf("disable flight mode: %v", err)
}
if !disabled.Changed || disabled.PreviousMode != 7 || disabled.CurrentMode != 1 ||
disabled.FlightMode || disabled.RadioOff {
t.Fatalf("disable result = %#v", disabled)
}
enabled, err := manager.SetFlight(context.Background(), id, true)
if err != nil {
t.Fatalf("enable flight mode: %v", err)
}
if !enabled.Changed || enabled.PreviousMode != 1 || enabled.CurrentMode != 0 ||
!enabled.FlightMode || !enabled.RadioOff {
t.Fatalf("enable result = %#v", enabled)
}
if openedPath != "/dev/wwan0qmi0" {
t.Fatalf("QMI path = %q", openedPath)
}
if len(session.setModes) != 2 || session.setModes[0] != qmi.ModeOnline || session.setModes[1] != qmi.ModeLowPower {
t.Fatalf("QMI modes = %v", session.setModes)
}
if session.closeCount != 2 {
t.Fatalf("QMI close count = %d", session.closeCount)
}
if atOpener.openCount != 0 {
t.Fatalf("AT opener used %d times for native QMI flight mode", atOpener.openCount)
}
entry, err := manager.Get(id)
if err != nil {
t.Fatal(err)
}
if entry.Snapshot == nil || entry.Snapshot.OperatingMode != 0 || !entry.Snapshot.FlightMode {
t.Fatalf("snapshot = %#v", entry.Snapshot)
}
}
func TestSetFlightDoesNotFallBackToUnsupportedATWhenQMIUnavailable(t *testing.T) {
manager, atOpener, id := newStartedNativeQMITestManager(t)
wantErr := errors.New("QMI DMS unavailable")
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
return nil, wantErr
}
if _, err := manager.SetFlight(context.Background(), id, false); !errors.Is(err, wantErr) {
t.Fatalf("SetFlight error = %v, want %v", err, wantErr)
}
if atOpener.openCount != 0 {
t.Fatalf("AT opener used %d times after QMI failure", atOpener.openCount)
}
}
func TestSetFlightWaitsForAsynchronousQMIModeTransition(t *testing.T) {
manager, atOpener, id := newStartedNativeQMITestManager(t)
session := &fakeQMIRadioSession{
mode: qmi.ModeShutdown,
getModes: []qmi.OperatingMode{qmi.ModeShutdown, qmi.ModeShutdown, qmi.ModeOnline},
}
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
return session, nil
}
result, err := manager.SetFlight(context.Background(), id, false)
if err != nil {
t.Fatalf("disable flight mode: %v", err)
}
if !result.Changed || result.PreviousMode != 7 || result.CurrentMode != 1 || result.FlightMode {
t.Fatalf("result = %#v", result)
}
if len(session.setModes) != 1 || session.setModes[0] != qmi.ModeOnline {
t.Fatalf("QMI modes = %v", session.setModes)
}
if atOpener.openCount != 0 {
t.Fatalf("AT opener used %d times during QMI transition", atOpener.openCount)
}
}
func TestSetFlightPreservesRawCFUNZero(t *testing.T) { func TestSetFlightPreservesRawCFUNZero(t *testing.T) {
client := &transcriptClient{steps: []clientStep{ client := &transcriptClient{steps: []clientStep{
{command: "AT+CFUN?", response: okResponse("+CFUN: 0")}, {command: "AT+CFUN?", response: okResponse("+CFUN: 0")},
+325 -29
View File
@@ -13,6 +13,40 @@ import (
var apnPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$`) var apnPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$`)
// ValidAPN reports whether value can safely be used as a modem PDP-context APN.
// An empty value is valid and means that the modem/operator default should be used.
func ValidAPN(value string) bool {
value = strings.TrimSpace(value)
return value == "" || apnPattern.MatchString(value)
}
func validNetworkCredential(value string) bool {
if len(value) > 128 || strings.ContainsAny(value, "\r\n\x00\"") {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func normalizeNetworkAuthentication(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "NONE":
return "NONE"
case "PAP":
return "PAP"
case "CHAP":
return "CHAP"
case "PAP_OR_CHAP":
return "PAP_OR_CHAP"
default:
return ""
}
}
func (manager *Manager) SetNetwork( func (manager *Manager) SetNetwork(
ctx context.Context, ctx context.Context,
id string, id string,
@@ -23,9 +57,16 @@ func (manager *Manager) SetNetwork(
return NetworkResult{}, err return NetworkResult{}, err
} }
apn := strings.TrimSpace(request.APN) apn := strings.TrimSpace(request.APN)
if request.Enabled && !apnPattern.MatchString(apn) { if request.Enabled && !ValidAPN(apn) {
return NetworkResult{}, ErrInvalidNetworkAPN return NetworkResult{}, ErrInvalidNetworkAPN
} }
if !validNetworkCredential(request.Username) || !validNetworkCredential(request.Password) {
return NetworkResult{}, errors.New("APN username or password contains unsupported characters")
}
authentication := normalizeNetworkAuthentication(request.Authentication)
if authentication == "" {
return NetworkResult{}, errors.New("authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
}
ipVersion := normalizeIPVersion(request.IPVersion) ipVersion := normalizeIPVersion(request.IPVersion)
if ipVersion == "" { if ipVersion == "" {
return NetworkResult{}, errors.New("IP version must be IP, IPV6, or IPV4V6") return NetworkResult{}, errors.New("IP version must be IP, IPV6, or IPV4V6")
@@ -43,8 +84,54 @@ func (manager *Manager) SetNetwork(
} }
} }
candidate := manager.candidateFor(state) candidate := manager.candidateFor(state)
if candidate.QMIControl != "" && candidate.NetworkInterface != "" { backend := strings.ToLower(strings.TrimSpace(request.Backend))
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion) if backend == "" {
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
backend = "qmi"
} else {
backend = "at"
}
}
if backend != "at" && backend != "qmi" {
return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend)
}
if backend == "qmi" {
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
// from its temporary profile. Do not return that output when the
// profile contains credentials.
return NetworkResult{}, errors.New("authenticated QMI cellular data operation failed")
}
return result, err
} }
client, err := manager.clientLocked(ctx, state, candidate) client, err := manager.clientLocked(ctx, state, candidate)
@@ -53,13 +140,32 @@ func (manager *Manager) SetNetwork(
return NetworkResult{}, err return NetworkResult{}, err
} }
if request.Enabled { if request.Enabled {
commands := []string{ type networkCommand struct {
fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn), value string
"AT+CGATT=1", sensitive bool
"AT+CGACT=1,1",
} }
commands := []networkCommand{
{value: fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn)},
}
if authentication != "NONE" {
authCode := map[string]int{"PAP": 1, "CHAP": 2, "PAP_OR_CHAP": 3}[authentication]
commands = append(commands, networkCommand{
value: fmt.Sprintf(`AT+CGAUTH=1,%d,"%s","%s"`, authCode, request.Username, request.Password),
sensitive: true,
})
}
commands = append(commands,
networkCommand{value: "AT+CGATT=1"},
networkCommand{value: "AT+CGACT=1,1"},
)
for _, command := range commands { for _, command := range commands {
if _, err := manager.command(ctx, client, command); err != nil { var err error
if command.sensitive {
_, err = manager.sensitiveCommand(ctx, client, command.value)
} else {
_, err = manager.command(ctx, client, command.value)
}
if err != nil {
manager.setResult(id, state, nil, err) manager.setResult(id, state, nil, err)
return NetworkResult{}, err return NetworkResult{}, err
} }
@@ -191,6 +297,19 @@ func usbNetModeName(mode int) string {
} }
func (manager *Manager) OperatorSelection(ctx context.Context, id string) (OperatorSelection, error) { func (manager *Manager) OperatorSelection(ctx context.Context, id string) (OperatorSelection, error) {
state, err := manager.lookup(id)
if err != nil {
return OperatorSelection{}, err
}
candidate := manager.candidateFor(state)
if isNativeQMICandidate(candidate) {
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return OperatorSelection{}, err
}
return manager.nativeQMIOperatorSelectionLocked(ctx, candidate)
}
response, err := manager.ExecuteAT(ctx, id, "AT+COPS?") response, err := manager.ExecuteAT(ctx, id, "AT+COPS?")
if err != nil { if err != nil {
return OperatorSelection{}, err return OperatorSelection{}, err
@@ -225,7 +344,7 @@ func (manager *Manager) SetOperatorSelection(
accessTechnologyValue *int, accessTechnologyValue *int,
) (OperatorSelection, error) { ) (OperatorSelection, error) {
result := OperatorSelection{Mode: 0} result := OperatorSelection{Mode: 0}
command := "AT+COPS=0" command := ""
if !automatic { if !automatic {
plmn = strings.TrimSpace(plmn) plmn = strings.TrimSpace(plmn)
if len(plmn) < 5 || len(plmn) > 6 || strings.IndexFunc(plmn, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { if len(plmn) < 5 || len(plmn) > 6 || strings.IndexFunc(plmn, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
@@ -254,6 +373,18 @@ func (manager *Manager) SetOperatorSelection(
if err := manager.validateActive(id, state); err != nil { if err := manager.validateActive(id, state); err != nil {
return OperatorSelection{}, err return OperatorSelection{}, err
} }
candidate := manager.candidateFor(state)
if isNativeQMICandidate(candidate) {
selection, err := manager.setNativeQMIOperatorSelectionLocked(
ctx,
candidate,
automatic,
plmn,
accessTechnologyValue,
)
manager.setResult(id, state, nil, err)
return selection, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil { if err != nil {
manager.setResult(id, state, nil, err) manager.setResult(id, state, nil, err)
@@ -265,28 +396,193 @@ func (manager *Manager) SetOperatorSelection(
// the lock is not aborted while registration is still in progress. // the lock is not aborted while registration is still in progress.
lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout) lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
defer cancel() defer cancel()
if _, err := client.Execute(lockCtx, command); err != nil { if automatic {
manager.setResult(id, state, nil, errors.New("operator selection command failed")) result, err = restoreAutomaticOperatorSelection(lockCtx, client)
manager.setResult(id, state, nil, err)
return result, err
}
response, err := client.Execute(lockCtx, command)
if err != nil || !response.OK() {
if err == nil {
err = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
rollbackOperatorSelection(manager, client)
wrapped := fmt.Errorf("manual operator selection failed and automatic selection was restored: %w", err)
manager.setResult(id, state, nil, wrapped)
return OperatorSelection{}, wrapped
}
actual, err := queryOperatorSelection(lockCtx, client)
if err != nil {
rollbackOperatorSelection(manager, client)
manager.setResult(id, state, nil, err)
return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err)
}
if actual.Mode != 1 || actual.Operator != plmn {
rollbackOperatorSelection(manager, client)
err := fmt.Errorf("network %s did not accept registration; automatic selection was restored (modem reported mode=%d operator=%q)", plmn, actual.Mode, actual.Operator)
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err return OperatorSelection{}, err
} }
if !automatic { result = actual
response, err := client.Execute(lockCtx, "AT+COPS?")
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err)
}
actual, err := parseOperatorSelection(response)
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
if actual.Mode != 1 || actual.Operator != plmn {
err := fmt.Errorf("network %s did not accept registration; modem reports mode=%d operator=%q", plmn, actual.Mode, actual.Operator)
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
result = actual
}
manager.setResult(id, state, nil, nil) manager.setResult(id, state, nil, nil)
return result, nil return result, nil
} }
func queryOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) {
response, err := client.Execute(ctx, "AT+COPS?")
if err != nil {
return OperatorSelection{}, err
}
if !response.OK() {
return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
return parseOperatorSelection(response)
}
// restoreAutomaticOperatorSelection clears both a manual PLMN latch and an
// old RAT-only scan restriction. The latter is important on EC20 modules:
// COPS=0 alone can remain effectively LTE-only after an earlier lock, unlike a
// phone's normal automatic GSM/WCDMA/LTE acquisition policy.
func restoreAutomaticOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) {
// Older firmware may not implement nwscanmode; COPS auto is still useful in
// that case, so this compatibility reset is best effort.
_, _ = client.Execute(ctx, `AT+QCFG="nwscanmode",0,1`)
_, _ = client.Execute(ctx, "AT+COPS=2")
response, err := client.Execute(ctx, "AT+COPS=0")
if err != nil {
return OperatorSelection{}, err
}
if !response.OK() {
return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
actual, err := queryOperatorSelection(ctx, client)
if err != nil {
return OperatorSelection{}, fmt.Errorf("verify automatic operator selection: %w", err)
}
if actual.Mode != 0 {
return OperatorSelection{}, fmt.Errorf("modem did not enter automatic operator selection (mode=%d operator=%q)", actual.Mode, actual.Operator)
}
return actual, nil
}
func rollbackOperatorSelection(manager *Manager, client modem.Client) {
rollbackCtx, cancel := context.WithTimeout(context.Background(), manager.longTimeout)
defer cancel()
_, _ = restoreAutomaticOperatorSelection(rollbackCtx, client)
}
// ReRegisterOperator detaches from the network and reapplies the modem's
// current automatic/manual selection. This is intentionally different from a
// passive refresh: it forces a new registration attempt without changing the
// user's lock policy.
func (manager *Manager) ReRegisterOperator(ctx context.Context, id string) (OperatorSelection, error) {
state, err := manager.lookup(id)
if err != nil {
return OperatorSelection{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
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)
return OperatorSelection{}, err
}
longCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
defer cancel()
current, err := queryOperatorSelection(longCtx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
manual := current.Mode == 1 || current.Mode == 4
if manual && !decimalPLMN(current.Operator) {
response, formatErr := client.Execute(longCtx, "AT+COPS=3,2")
if formatErr != nil || !response.OK() {
if formatErr == nil {
formatErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
}
manager.setResult(id, state, nil, formatErr)
return OperatorSelection{}, formatErr
}
current, err = queryOperatorSelection(longCtx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorSelection{}, err
}
manual = current.Mode == 1 || current.Mode == 4
}
if !manual {
result, restoreErr := restoreAutomaticOperatorSelection(longCtx, client)
manager.setResult(id, state, nil, restoreErr)
return result, restoreErr
}
desired := ""
if manual {
if !decimalPLMN(current.Operator) {
return OperatorSelection{}, errors.New("current manual operator is not available as a numeric PLMN")
}
desired = fmt.Sprintf(`AT+COPS=1,2,"%s"`, current.Operator)
if code, ok := accessTechnologyCode(current.AccessTechnology); ok {
desired += fmt.Sprintf(",%d", code)
}
}
for _, command := range []string{"AT+COPS=2", desired} {
response, executeErr := client.Execute(longCtx, command)
if executeErr != nil {
manager.setResult(id, state, nil, executeErr)
return OperatorSelection{}, executeErr
}
if !response.OK() {
executeErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
manager.setResult(id, state, nil, executeErr)
return OperatorSelection{}, executeErr
}
}
result, err := queryOperatorSelection(longCtx, client)
manager.setResult(id, state, nil, err)
if err != nil {
return OperatorSelection{}, err
}
return result, nil
}
func decimalPLMN(value string) bool {
value = strings.TrimSpace(value)
return (len(value) == 5 || len(value) == 6) && strings.IndexFunc(value, func(r rune) bool {
return r < '0' || r > '9'
}) < 0
}
func accessTechnologyCode(name string) (int, bool) {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "GSM":
return 0, true
case "UTRAN":
return 2, true
case "EDGE":
return 3, true
case "HSDPA":
return 4, true
case "HSUPA":
return 5, true
case "HSPA":
return 6, true
case "LTE":
return 7, true
case "NR5G":
return 9, true
default:
return 0, false
}
}
+224 -25
View File
@@ -4,9 +4,13 @@ package device
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"hash/fnv"
"net"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"time" "time"
@@ -19,6 +23,9 @@ func setQMINetwork(
enabled bool, enabled bool,
apn string, apn string,
ipVersion string, ipVersion string,
username string,
password string,
authentication string,
) (NetworkResult, error) { ) (NetworkResult, error) {
qmiNetwork, err := exec.LookPath("qmi-network") qmiNetwork, err := exec.LookPath("qmi-network")
if err != nil { if err != nil {
@@ -31,7 +38,20 @@ func setQMINetwork(
profilePath := profile.Name() profilePath := profile.Name()
defer os.Remove(profilePath) defer os.Remove(profilePath)
ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion] ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion]
if _, err := fmt.Fprintf(profile, "APN=%s\nIP_TYPE=%s\nPROXY=yes\n", apn, ipType); err != nil { profileText := fmt.Sprintf("IP_TYPE=%s\nPROXY=yes\n", ipType)
if apn != "" {
profileText = "APN=" + apn + "\n" + profileText
}
if username != "" {
profileText += "APN_USER=" + shellProfileValue(username) + "\n"
}
if password != "" {
profileText += "APN_PASS=" + shellProfileValue(password) + "\n"
}
if authentication != "" && authentication != "NONE" {
profileText += "APN_AUTH=" + shellProfileValue(strings.ToLower(authentication)) + "\n"
}
if _, err := fmt.Fprint(profile, profileText); err != nil {
_ = profile.Close() _ = profile.Close()
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err) return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
} }
@@ -54,36 +74,42 @@ func setQMINetwork(
lowerDetail := strings.ToLower(detail) lowerDetail := strings.ToLower(detail)
idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") || idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") ||
strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network")) strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network"))
if !idempotentStop { idempotentStart := enabled && (strings.Contains(lowerDetail, "already started") ||
strings.Contains(lowerDetail, "already connected"))
if !idempotentStop && !idempotentStart {
return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail) return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail)
} }
} }
if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil { ipCommand, lookErr := exec.LookPath("ip")
linkAction := "down" if lookErr != nil {
if enabled { return NetworkResult{}, fmt.Errorf("%w: install iproute2 to control %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
linkAction = "up" }
} linkAction := "down"
linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput() if enabled {
if linkErr != nil { linkAction = "up"
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput))) }
} linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
if linkErr != nil {
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
} }
if enabled { if enabled {
if busybox, lookErr := exec.LookPath("busybox"); lookErr == nil { busybox, busyboxErr := exec.LookPath("busybox")
dhcpOutput, dhcpErr := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", candidate.NetworkInterface).CombinedOutput() if busyboxErr != nil {
if dhcpErr != nil { return NetworkResult{}, fmt.Errorf("%w: busybox udhcpc is required for %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
defer cancelRollback()
_, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput()
if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil {
_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
}
return NetworkResult{}, fmt.Errorf("QMI session started but DHCP failed: %w: %s", dhcpErr, strings.TrimSpace(string(dhcpOutput)))
}
if value := strings.TrimSpace(string(dhcpOutput)); value != "" {
detail = strings.TrimSpace(detail + "\n" + value)
}
} }
dhcpDetail, dhcpErr := configureExportProxyDHCP(ctx, busybox, ipCommand, candidate.NetworkInterface)
if dhcpErr != nil {
rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
defer cancelRollback()
clearExportProxyRoute(rollbackCtx, candidate.NetworkInterface)
_, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput()
_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
return NetworkResult{}, fmt.Errorf("QMI session started but protected DHCP failed: %w", dhcpErr)
}
detail = strings.TrimSpace(detail + "\n" + dhcpDetail)
} else {
clearExportProxyRoute(ctx, candidate.NetworkInterface)
_, _ = exec.CommandContext(ctx, ipCommand, "-4", "addr", "flush", "dev", candidate.NetworkInterface, "scope", "global").CombinedOutput()
} }
return NetworkResult{ return NetworkResult{
Enabled: enabled, Enabled: enabled,
@@ -96,4 +122,177 @@ func setQMINetwork(
}, nil }, nil
} }
func shellProfileValue(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
// exportProxyRouteIdentity must stay in sync with the Export Proxy plugin's
// Linux socket mark. Unmarked host traffic never sees the cellular default
// route; only plugin sockets carrying this mark are policy-routed to it.
func exportProxyRouteIdentity(networkInterface string) (mark uint32, table, priority int) {
hash := fnv.New32a()
_, _ = hash.Write([]byte(networkInterface))
value := hash.Sum32()
mark = 0x56000000 | (value & 0x00ffffff)
table = 20000 + int(value%10000)
priority = 20000 + int(value%10000)
return
}
func configureExportProxyDHCP(ctx context.Context, busybox, ipCommand, networkInterface string) (string, error) {
lease, err := os.CreateTemp("", "vocat-dhcp-lease-*.env")
if err != nil {
return "", err
}
leasePath := lease.Name()
_ = lease.Close()
_ = os.Remove(leasePath)
defer os.Remove(leasePath)
script, err := os.CreateTemp("", "vocat-udhcpc-*.sh")
if err != nil {
return "", err
}
scriptPath := script.Name()
defer os.Remove(scriptPath)
scriptText := fmt.Sprintf(`#!/bin/sh
case "$1" in
bound|renew)
(umask 077; printf 'ip=%%s\nsubnet=%%s\nrouter=%%s\ndns=%%s\n' "$ip" "$subnet" "$router" "$dns" > %q)
;;
esac
exit 0
`, leasePath)
if _, err := script.WriteString(scriptText); err != nil {
_ = script.Close()
return "", err
}
if err := script.Chmod(0o700); err != nil {
_ = script.Close()
return "", err
}
if err := script.Close(); err != nil {
return "", err
}
output, err := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", networkInterface, "-s", scriptPath).CombinedOutput()
if err != nil {
if strings.Contains(strings.ToLower(string(output)), "address family not supported") {
return "", fmt.Errorf("udhcpc cannot open its link-layer socket: allow AF_PACKET in the vocat systemd service RestrictAddressFamilies setting: %w", err)
}
return "", fmt.Errorf("udhcpc: %w: %s", err, strings.TrimSpace(string(output)))
}
raw, err := os.ReadFile(leasePath)
if err != nil {
return "", fmt.Errorf("read DHCP lease: %w", err)
}
values := make(map[string]string)
for _, line := range strings.Split(string(raw), "\n") {
key, value, found := strings.Cut(line, "=")
if found {
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
address := net.ParseIP(values["ip"]).To4()
maskIP := net.ParseIP(values["subnet"]).To4()
if address == nil || maskIP == nil {
return "", errors.New("DHCP returned no valid IPv4 address/subnet")
}
mask := net.IPMask(maskIP)
ones, bits := mask.Size()
if bits != 32 || ones < 0 {
return "", errors.New("DHCP returned an invalid IPv4 subnet")
}
network := address.Mask(mask)
routers := strings.Fields(values["router"])
if len(routers) > 0 && net.ParseIP(routers[0]).To4() == nil {
return "", errors.New("DHCP returned an invalid IPv4 gateway")
}
if result, addrErr := exec.CommandContext(ctx, ipCommand, "-4", "addr", "replace", fmt.Sprintf("%s/%d", address.String(), ones), "dev", networkInterface).CombinedOutput(); addrErr != nil {
return "", fmt.Errorf("configure cellular address: %w: %s", addrErr, strings.TrimSpace(string(result)))
}
mark, table, priority := exportProxyRouteIdentity(networkInterface)
clearExportProxyRoute(ctx, networkInterface)
connectedCIDR := fmt.Sprintf("%s/%d", network.String(), ones)
if result, routeErr := exec.CommandContext(ctx, ipCommand, "-4", "route", "replace", "table", strconv.Itoa(table), connectedCIDR, "dev", networkInterface, "scope", "link", "src", address.String()).CombinedOutput(); routeErr != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("install protected connected route: %w: %s", routeErr, strings.TrimSpace(string(result)))
}
defaultArgs := []string{"-4", "route", "replace", "table", strconv.Itoa(table), "default"}
if len(routers) > 0 {
defaultArgs = append(defaultArgs, "via", routers[0])
}
defaultArgs = append(defaultArgs, "dev", networkInterface, "onlink")
if result, routeErr := exec.CommandContext(ctx, ipCommand, defaultArgs...).CombinedOutput(); routeErr != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("install protected default route: %w: %s", routeErr, strings.TrimSpace(string(result)))
}
markText := fmt.Sprintf("0x%x", mark)
result, err := exec.CommandContext(ctx, ipCommand, "rule", "add", "priority", strconv.Itoa(priority), "fwmark", markText, "lookup", strconv.Itoa(table)).CombinedOutput()
if err != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("install protected routing rule: %w: %s", err, strings.TrimSpace(string(result)))
}
if err := writeExportProxyDNS(networkInterface, strings.Fields(values["dns"])); err != nil {
clearExportProxyRoute(ctx, networkInterface)
return "", fmt.Errorf("publish protected DNS configuration: %w", err)
}
return fmt.Sprintf("protected DHCP lease %s/%d", address.String(), ones), nil
}
func exportProxyDNSPath(networkInterface string) string {
safeName := strings.Map(func(character rune) rune {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
return character
}
return '_'
}, networkInterface)
return "/run/vocat/cellular-" + safeName + ".dns"
}
func writeExportProxyDNS(networkInterface string, servers []string) error {
valid := make([]string, 0, len(servers))
for _, server := range servers {
if address := net.ParseIP(server); address != nil {
valid = append(valid, address.String())
}
}
if len(valid) == 0 {
// This is used only by marked Export Proxy sockets. It never changes the
// host resolver and is merely a fallback for carriers omitting DHCP DNS.
valid = []string{"1.1.1.1", "8.8.8.8"}
}
if err := os.MkdirAll("/run/vocat", 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp("/run/vocat", ".cellular-dns-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, err := temporary.WriteString(strings.Join(valid, "\n") + "\n"); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Chmod(0o644); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(temporaryPath, exportProxyDNSPath(networkInterface))
}
func clearExportProxyRoute(ctx context.Context, networkInterface string) {
_ = os.Remove(exportProxyDNSPath(networkInterface))
ipCommand, err := exec.LookPath("ip")
if err != nil {
return
}
mark, table, priority := exportProxyRouteIdentity(networkInterface)
_, _ = exec.CommandContext(ctx, ipCommand, "rule", "del", "priority", strconv.Itoa(priority), "fwmark", fmt.Sprintf("0x%x", mark), "lookup", strconv.Itoa(table)).CombinedOutput()
_, _ = exec.CommandContext(ctx, ipCommand, "-4", "route", "flush", "table", strconv.Itoa(table)).CombinedOutput()
}
const managerCommandCleanupTimeout = 15 * time.Second const managerCommandCleanupTimeout = 15 * time.Second
+3
View File
@@ -15,6 +15,9 @@ func setQMINetwork(
bool, bool,
string, string,
string, string,
string,
string,
string,
) (NetworkResult, error) { ) (NetworkResult, error) {
return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable) return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable)
} }
+131 -1
View File
@@ -3,7 +3,10 @@ package device
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"vocat/internal/modem"
) )
func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) { func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
@@ -35,6 +38,52 @@ func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
client.assertDone(t) client.assertDone(t)
} }
func TestSetNetworkATBackendAppliesPAPCredentials(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
{command: `AT+CGAUTH=1,1,"gg","p"`, response: okResponse()},
{command: "AT+CGATT=1", response: okResponse()},
{command: "AT+CGACT=1,1", response: okResponse()},
}}
manager, id := newStartedTestManager(t, client)
if _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
Username: "gg", Password: "p", Authentication: "PAP",
}); err != nil {
t.Fatalf("enable authenticated network: %v", err)
}
client.assertDone(t)
}
func TestSetNetworkDoesNotExposeAPNCredentialsInErrorsOrState(t *testing.T) {
const username = "private-user"
const password = "private-password"
command := `AT+CGAUTH=1,1,"` + username + `","` + password + `"`
client := &transcriptClient{steps: []clientStep{
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
{command: command, err: &modem.CommandError{Command: command, Final: "ERROR"}},
}}
manager, id := newStartedTestManager(t, client)
_, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
Username: username, Password: password, Authentication: "PAP",
})
if err == nil {
t.Fatal("SetNetwork() error = nil")
}
if strings.Contains(err.Error(), username) || strings.Contains(err.Error(), password) || strings.Contains(err.Error(), "AT+CGAUTH") {
t.Fatalf("SetNetwork() exposed credentials: %q", err)
}
entry, getErr := manager.Get(id)
if getErr != nil {
t.Fatal(getErr)
}
if strings.Contains(entry.LastError, username) || strings.Contains(entry.LastError, password) || strings.Contains(entry.LastError, "AT+CGAUTH") {
t.Fatalf("device state exposed credentials: %q", entry.LastError)
}
client.assertDone(t)
}
func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) { func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) {
client := &transcriptClient{} client := &transcriptClient{}
manager, id := newStartedTestManager(t, client) manager, id := newStartedTestManager(t, client)
@@ -74,7 +123,10 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
client := &transcriptClient{steps: []clientStep{ client := &transcriptClient{steps: []clientStep{
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()}, {command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46000",7`)}, {command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46000",7`)},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()}, {command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}} }}
manager, id := newStartedTestManager(t, client) manager, id := newStartedTestManager(t, client)
act := 7 act := 7
@@ -89,7 +141,7 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("automatic selection: %v", err) t.Fatalf("automatic selection: %v", err)
} }
if selection.Mode != 0 || selection.Operator != "" { if selection.Mode != 0 || selection.Operator != "46001" {
t.Fatalf("automatic selection = %#v", selection) t.Fatalf("automatic selection = %#v", selection)
} }
client.assertDone(t) client.assertDone(t)
@@ -99,6 +151,10 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
client := &transcriptClient{steps: []clientStep{ client := &transcriptClient{steps: []clientStep{
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()}, {command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
{command: "AT+COPS?", response: okResponse("+COPS: 0")}, {command: "AT+COPS?", response: okResponse("+COPS: 0")},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}} }}
manager, id := newStartedTestManager(t, client) manager, id := newStartedTestManager(t, client)
act := 7 act := 7
@@ -107,3 +163,77 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
} }
client.assertDone(t) client.assertDone(t)
} }
func TestOperatorSelectionCommandFailureRestoresAutomaticMode(t *testing.T) {
selectionErr := errors.New("+CME ERROR: 30")
client := &transcriptClient{steps: []clientStep{
{command: `AT+COPS=1,2,"46000",7`, err: selectionErr},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
act := 7
_, err := manager.SetOperatorSelection(context.Background(), id, false, "46000", &act)
if !errors.Is(err, selectionErr) {
t.Fatalf("error = %v, want wrapped selection error", err)
}
client.assertDone(t)
}
func TestReRegisterOperatorReappliesAutomaticMode(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
selection, err := manager.ReRegisterOperator(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if selection.Mode != 0 || selection.Operator != "46001" {
t.Fatalf("selection = %#v", selection)
}
client.assertDone(t)
}
func TestReRegisterOperatorPreservesManualLock(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)},
{command: "AT+COPS=2", response: okResponse()},
{command: `AT+COPS=1,2,"46003",7`, response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)},
}}
manager, id := newStartedTestManager(t, client)
selection, err := manager.ReRegisterOperator(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if selection.Mode != 1 || selection.Operator != "46003" || selection.AccessTechnology != "LTE" {
t.Fatalf("selection = %#v", selection)
}
client.assertDone(t)
}
func TestReRegisterOperatorRecoversDeregisteredModeWithAutomaticSelection(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "AT+COPS?", response: okResponse(`+COPS: 2`)},
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
{command: "AT+COPS=2", response: okResponse()},
{command: "AT+COPS=0", response: okResponse()},
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
}}
manager, id := newStartedTestManager(t, client)
selection, err := manager.ReRegisterOperator(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if selection.Mode != 0 || selection.Operator != "46001" {
t.Fatalf("selection = %#v", selection)
}
client.assertDone(t)
}
+86 -49
View File
@@ -3,14 +3,18 @@ package device
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"regexp"
"strings" "strings"
"time" "time"
"vocat/internal/netguard"
) )
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the // es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
@@ -24,25 +28,33 @@ import (
// header.functionExecutionStatus (with statusCodeData.message holding the // header.functionExecutionStatus (with statusCodeData.message holding the
// human-readable failure, e.g. "The matchingID is not found"). // human-readable failure, e.g. "The matchingID is not found").
type es9pClient struct { type es9pClient struct {
smdp string smdp string
http *http.Client endpoint *url.URL
http *http.Client
} }
func newES9PClient(smdp string) *es9pClient { var smdpAddressPattern = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$`)
// The eUICC — not the host — is the root of trust for RSP: during
// AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
// its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature smdp = strings.TrimSpace(smdp)
// the card will accept. The host TLS layer is transport only, and a minimal if !smdpAddressPattern.MatchString(smdp) {
// embedded box may ship no CA bundle (this is exactly what broke on the test return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
// machine), so we don't anchor host TLS to system roots. InsecureSkipVerify }
// is safe here specifically because the card does the authoritative check. candidate, err := url.Parse("https://" + smdp)
transport := &http.Transport{ if err != nil || candidate.Hostname() == "" || candidate.User != nil ||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor (candidate.Path != "" && candidate.Path != "/") || candidate.RawQuery != "" || candidate.Fragment != "" {
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
}
candidate.Path = ""
validated, err := netguard.ValidatePublicURL(ctx, candidate.String(), true)
if err != nil {
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
} }
return &es9pClient{ return &es9pClient{
smdp: strings.TrimSpace(smdp), smdp: validated.Host,
http: &http.Client{Timeout: 90 * time.Second, Transport: transport}, endpoint: validated,
} http: netguard.NewPublicHTTPClient(90*time.Second, true),
}, nil
} }
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s // es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
@@ -80,12 +92,13 @@ type es9pStatusCodeData struct {
// is decided the way lpac decides it: a non-success execution status, or a // is decided the way lpac decides it: a non-success execution status, or a
// missing required output field, yields an es9pError carrying the SM-DP+ message. // missing required output field, yields an es9pError carrying the SM-DP+ message.
func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) { func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) {
url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function endpoint := *c.endpoint
endpoint.Path = "/gsma/rsp2/es9plus/" + function
body, err := json.Marshal(request) body, err := json.Marshal(request)
if err != nil { if err != nil {
return nil, err return nil, err
} }
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -149,31 +162,31 @@ func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
// human-readable failure when the SM-DP+ omits statusCodeData.message. Table // human-readable failure when the SM-DP+ omits statusCodeData.message. Table
// mirrors lpac's euicc/es9p_errors.c. // mirrors lpac's euicc/es9p_errors.c.
var es9pErrorTable = map[[2]string]string{ var es9pErrorTable = map[[2]string]string{
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile", {"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid", {"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
{"8.1.1", "2.2"}: "EID is missing in the context of this order", {"8.1.1", "2.2"}: "EID is missing in the context of this order",
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID", {"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
{"8.1.1", "3.8"}: "EID doesn't match the expected value", {"8.1.1", "3.8"}: "EID doesn't match the expected value",
{"8.1.2", "6.1"}: "EUM Certificate is invalid", {"8.1.2", "6.1"}: "EUM Certificate is invalid",
{"8.1.2", "6.3"}: "EUM Certificate has expired", {"8.1.2", "6.3"}: "EUM Certificate has expired",
{"8.1.3", "6.1"}: "eUICC Certificate is invalid", {"8.1.3", "6.1"}: "eUICC Certificate is invalid",
{"8.1.3", "6.3"}: "eUICC Certificate has expired", {"8.1.3", "6.3"}: "eUICC Certificate has expired",
{"8.2", "1.2"}: "Profile has not yet been released", {"8.2", "1.2"}: "Profile has not yet been released",
{"8.2", "3.7"}: "BPP is not available for a new binding", {"8.2", "3.7"}: "BPP is not available for a new binding",
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type", {"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device", {"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID", {"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
{"8.2.6", "3.3"}: "Conflicting MatchingID value", {"8.2.6", "3.3"}: "Conflicting MatchingID value",
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused", {"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
{"8.2.7", "2.2"}: "Confirmation Code is missing", {"8.2.7", "2.2"}: "Confirmation Code is missing",
{"8.2.7", "3.8"}: "Confirmation Code is refused", {"8.2.7", "3.8"}: "Confirmation Code is refused",
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded", {"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address", {"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC", {"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
{"8.8.5", "4.1"}: "The Download order has expired", {"8.8.5", "4.1"}: "The Download order has expired",
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded", {"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown", {"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.", {"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
} }
func es9pErrorMessage(subjectCode, reasonCode string) string { func es9pErrorMessage(subjectCode, reasonCode string) string {
@@ -254,10 +267,10 @@ func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge,
// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download // es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
// authorization needed for PrepareDownload. // authorization needed for PrepareDownload.
type es9pAuthenticateResult struct { type es9pAuthenticateResult struct {
TransactionID string TransactionID string
ProfileMetadata []byte ProfileMetadata []byte
SmdpSigned2 []byte SmdpSigned2 []byte
SmdpSignature2 []byte SmdpSignature2 []byte
SmdpCertificate []byte SmdpCertificate []byte
} }
@@ -287,7 +300,7 @@ func (c *es9pClient) authenticateClient(ctx context.Context, transactionID strin
func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) { func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{ root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
"transactionId": transactionID, "transactionId": transactionID,
"prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse), "prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
}, "boundProfilePackage") }, "boundProfilePackage")
if err != nil { if err != nil {
@@ -300,10 +313,34 @@ func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID s
// for the download case). It is best-effort: the profile is already installed, so // for the download case). It is best-effort: the profile is already installed, so
// a notification failure is reported by the caller as a warning, not a failure. // a notification failure is reported by the caller as a warning, not a failure.
func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error { func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
_, err := c.call(ctx, "handleNotification", map[string]string{ endpoint := *c.endpoint
endpoint.Path = "/gsma/rsp2/es9plus/handleNotification"
body, err := json.Marshal(map[string]string{
"pendingNotification": es9pBase64Encode(pendingNotification), "pendingNotification": es9pBase64Encode(pendingNotification),
}) })
return err if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "gsma-rsp-lpad")
request.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("es9p handleNotification: %w", err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
// SGP.22 defines HandleNotification as a notification-handler function:
// success is an empty HTTP 204 response, not the JSON envelope returned by
// ordinary ES9+ request-response functions.
if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("es9p handleNotification: receiver returned HTTP %d", response.StatusCode)
}
return nil
} }
// cancelSession aborts an in-flight download so the SM-DP+ releases the // cancelSession aborts an in-flight download so the SM-DP+ releases the
+56 -3
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
) )
@@ -16,9 +17,15 @@ func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
t.Helper() t.Helper()
server := httptest.NewTLSServer(handler) server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close) t.Cleanup(server.Close)
client := newES9PClient(strings.TrimPrefix(server.URL, "https://")) endpoint, err := url.Parse(server.URL)
client.http = server.Client() if err != nil {
return client t.Fatal(err)
}
return &es9pClient{
smdp: strings.TrimPrefix(server.URL, "https://"),
endpoint: endpoint,
http: server.Client(),
}
} }
func successEnvelope(fields map[string]any) map[string]any { func successEnvelope(fields map[string]any) map[string]any {
@@ -33,6 +40,21 @@ func successEnvelope(fields map[string]any) map[string]any {
func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) } func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) }
func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
for _, address := range []string{
"https://rsp.example.com",
"127.0.0.1",
"169.254.169.254",
"rsp.example.com/unexpected/path",
"user:[email protected]",
"rsp.example.com\r\nX-Injected: yes",
} {
if _, err := newES9PClient(context.Background(), address); err == nil {
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
}
}
}
func TestInitiateAuthenticationSuccess(t *testing.T) { func TestInitiateAuthenticationSuccess(t *testing.T) {
signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09} signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09}
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) { client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
@@ -141,3 +163,34 @@ func TestGetBoundProfilePackageSuccess(t *testing.T) {
t.Fatalf("bpp = %X, want %X", got, pkg) t.Fatalf("bpp = %X, want %X", got, pkg)
} }
} }
func TestHandleNotificationRequiresHTTP204(t *testing.T) {
pending := []byte{0xBF, 0x37, 0x00}
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gsma/rsp2/es9plus/handleNotification" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" {
t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol"))
}
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
decoded, err := base64.StdEncoding.DecodeString(request["pendingNotification"])
if err != nil || !bytes.Equal(decoded, pending) {
t.Errorf("pendingNotification = %q (%X), err=%v", request["pendingNotification"], decoded, err)
}
w.WriteHeader(http.StatusNoContent)
})
if err := client.handleNotification(context.Background(), pending); err != nil {
t.Fatalf("handleNotification: %v", err)
}
client = newTestES9P(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(successEnvelope(nil))
})
if err := client.handleNotification(context.Background(), pending); err == nil || !strings.Contains(err.Error(), "HTTP 200") {
t.Fatalf("HTTP 200 error = %v", err)
}
}
+493 -62
View File
@@ -8,8 +8,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
"vocat/internal/i18n" "vocat/internal/i18n"
"vocat/internal/modem" "vocat/internal/modem"
"vocat/internal/pcsc"
) )
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough. // eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
@@ -26,6 +29,12 @@ import (
// isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10). // isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10).
const isdRAID = "A0000005591010FFFFFFFF8900000100" const isdRAID = "A0000005591010FFFFFFFF8900000100"
// xesimISDRAID is the alternate ISD-R application exposed by XeSIM cards.
// It implements the same ES10 interface, but is not selectable through the
// standard ...0100 AID. Selecting it is a read-only capability probe; profile
// state is never changed during discovery.
const xesimISDRAID = "A0000005591010FFFFFFFF8900000177"
// eSTK multi-SE products expose each eUICC storage through its own vendor // eSTK multi-SE products expose each eUICC storage through its own vendor
// ISD-R AID. The standard GSMA AID aliases one of them, so probing only that // ISD-R AID. The standard GSMA AID aliases one of them, so probing only that
// AID silently hides the second storage. // AID silently hides the second storage.
@@ -152,12 +161,20 @@ func encodeICCID(digits string) ([]byte, error) {
} }
func buildEnableProfileRequest(iccid string) ([]byte, error) { func buildEnableProfileRequest(iccid string) ([]byte, error) {
return buildEnableProfileRequestWithRefresh(iccid, true)
}
func buildEnableProfileRequestWithRefresh(iccid string, refresh bool) ([]byte, error) {
bcd, err := encodeICCID(iccid) bcd, err := encodeICCID(iccid)
if err != nil { if err != nil {
return nil, err return nil, err
} }
profileID := derConstruct(0xA0, derEncode(0x5A, bcd)) profileID := derConstruct(0xA0, derEncode(0x5A, bcd))
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. // parseCSIM extracts the payload and status word from an AT+CSIM response.
@@ -184,9 +201,76 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
// euiccChannel is an open logical channel to the eUICC's ISD-R. // euiccChannel is an open logical channel to the eUICC's ISD-R.
type euiccChannel struct { type euiccChannel struct {
manager *Manager manager *Manager
id string id string
channel int 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 // csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
@@ -237,6 +321,15 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
return channel, nil return channel, nil
} }
lastErr = err lastErr = err
if attempt == 0 && errors.Is(err, errNoLogicalChannel) &&
manager.releaseStaleEuiccChannel(ctx, id) {
// EC20 firmware exposes only one MANAGE CHANNEL slot. A canceled or
// interrupted APDU transaction can leave channel 1 allocated, after
// which every eSIM page load returns 6A81 until reboot. Closing the
// orphan while holding the shared UICC transaction lock makes the
// operation self-healing without disturbing an active AKA exchange.
continue
}
if !isTransientEuiccCME(err) { if !isTransientEuiccCME(err) {
return nil, err return nil, err
} }
@@ -253,11 +346,27 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
return nil, lastErr return nil, lastErr
} }
func (manager *Manager) releaseStaleEuiccChannel(ctx context.Context, id string) bool {
_, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x80, 0x01, 0x00})
return err == nil && sw == 0x9000
}
func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) { func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
return manager.openEuiccOnceAID(ctx, id, isdRAID) return manager.openEuiccOnceAID(ctx, id, isdRAID)
} }
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) { func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
state, lookupErr := manager.lookup(id)
if lookupErr != nil {
return nil, lookupErr
}
candidate := manager.candidateFor(state)
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 // MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
// firmware requires the explicit one-byte expected length: Le=00 opens a // firmware requires the explicit one-byte expected length: Le=00 opens a
// channel but then rejects SELECT ISD-R at the AT+CSIM layer. // channel but then rejects SELECT ISD-R at the AT+CSIM layer.
@@ -296,20 +405,95 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, nil return channel, nil
} }
// discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
// state. The vendor product applet is selected only as a read-only capability aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
// probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK aid, err := hex.DecodeString(aidHex)
// integration, the generic GSMA AID is not appended after an eSTK SE opens, if err != nil || len(aid) == 0 || len(aid) > 255 {
// because it aliases one of the same storages. 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,
})
if err != nil {
return nil, err
}
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
if err != nil || sw != 0x9000 || len(payload) != 1 {
session.Close()
if err != nil {
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
}
return nil, errNoLogicalChannel
}
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
aid, err := hex.DecodeString(aidHex)
if err != nil || len(aid) == 0 || len(aid) > 255 {
channel.close(context.Background())
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
}
selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
if err != nil || selectSW != 0x9000 {
channel.close(context.Background())
return nil, errNoEUICC
}
return channel, nil
}
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
// changing any profile state. The vendor product applet and candidate ISD-R
// applications are selected only as read-only capability probes. Per
// OpenEUICC's eSTK integration, generic AIDs are not appended after an eSTK SE
// opens, because the standard AID aliases one of the same storages.
func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string { func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string {
product, err := manager.openEuiccAID(ctx, id, estkProductAID) product, err := manager.openEuiccAID(ctx, id, estkProductAID)
if err != nil { if err == nil {
return []string{isdRAID} product.close(context.Background())
var found []string
for _, aid := range []string{estkSE0AID, estkSE1AID} {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
continue
}
channel.close(context.Background())
found = append(found, aid)
}
if len(found) > 0 {
return found
}
} }
product.close(context.Background())
var found []string var found []string
for _, aid := range []string{estkSE0AID, estkSE1AID} { for _, aid := range []string{isdRAID, xesimISDRAID} {
channel, err := manager.openEuiccAID(ctx, id, aid) channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil { if err != nil {
continue continue
@@ -317,10 +501,12 @@ func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []stri
channel.close(context.Background()) channel.close(context.Background())
found = append(found, aid) found = append(found, aid)
} }
if len(found) == 0 { if len(found) > 0 {
return []string{isdRAID} return found
} }
return found // Preserve the old error path for a physical SIM with no eUICC. The caller
// retries the standard AID once and returns ErrNoEUICC to the HTTP layer.
return []string{isdRAID}
} }
func isTransientEuiccCME(err error) bool { func isTransientEuiccCME(err error) bool {
@@ -331,8 +517,43 @@ func isTransientEuiccCME(err error) bool {
// close releases the logical channel (MANAGE CHANNEL close). // close releases the logical channel (MANAGE CHANNEL close).
func (channel *euiccChannel) close(ctx context.Context) { func (channel *euiccChannel) close(ctx context.Context) {
if channel.qmiSession != nil {
if channel.channel > 0 {
_ = channel.qmiSession.CloseLogicalChannel(ctx, channel.qmiSlot, byte(channel.channel))
}
_ = channel.qmiSession.Close()
channel.qmiSession = nil
return
}
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00} closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
_, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU) _, _, _ = channel.exchange(ctx, closeAPDU)
if channel.pcscSession != nil {
if channel.resetOnClose {
_ = channel.pcscSession.CloseWithReset()
} else {
_ = channel.pcscSession.Close()
}
channel.pcscSession = nil
}
}
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
}
return channel.manager.csim(ctx, channel.id, apdu)
} }
// transmit sends one APDU on the logical channel (CLA high nibble from insClass, // transmit sends one APDU on the logical channel (CLA high nibble from insClass,
@@ -340,7 +561,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
// and returns the assembled payload. // and returns the assembled payload.
func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) { func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel) apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
payload, sw, err := channel.manager.csim(ctx, channel.id, apdu) payload, sw, err := channel.exchange(ctx, apdu)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -349,7 +570,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
for sw>>8 == 0x61 && guard < 24 { for sw>>8 == 0x61 && guard < 24 {
guard++ guard++
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)} getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse) frag, nextSW, err := channel.exchange(ctx, getResponse)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -523,26 +744,35 @@ func validProfileICCID(iccid string) bool {
// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo. // ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) { func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
manager.esimMu.Lock() manager.lockESIM()
defer manager.esimMu.Unlock() defer manager.unlockESIM()
if manager.esimRecoveryActive(id) { if manager.esimRecoveryActive(id) {
if cached, ok := manager.cachedESIMInfo(id); ok { if cached, ok := manager.cachedESIMInfo(id); ok {
return cached, nil return cached, nil
} }
return EsimInfo{}, errESIMRecovering return EsimInfo{}, errESIMRecovering
} }
channel, err := manager.openEuicc(ctx, id) var lastErr error
if err != nil { for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
return EsimInfo{}, err channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
info := EsimInfo{AID: aid, Profiles: parseProfilesInfo(payload)}
manager.cacheESIMInfo(id, info)
return info, nil
} }
defer channel.close(context.Background()) if lastErr != nil {
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo return EsimInfo{}, lastErr
if err != nil {
return EsimInfo{}, err
} }
info := EsimInfo{Profiles: parseProfilesInfo(payload)} return EsimInfo{}, ErrNoEUICC
manager.cacheESIMInfo(id, info)
return info, nil
} }
// ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile. // ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile.
@@ -551,18 +781,43 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
if iccid == "" { if iccid == "" {
return errors.New("esim: an ICCID is required") return errors.New("esim: an ICCID is required")
} }
der, err := buildEnableProfileRequest(iccid) _, nativeQMI, nativeErr := manager.nativeQMIControl(id)
if err != nil { if nativeErr != nil {
return err return nativeErr
} }
manager.esimMu.Lock() manager.lockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil { if err := manager.waitForESIMRecovery(ctx, id); err != nil {
manager.esimMu.Unlock() manager.unlockESIM()
return err return err
} }
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex)) channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil { if err != nil {
manager.esimMu.Unlock() 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 return err
} }
@@ -574,13 +829,42 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// stays a sibling of A0, directly under BF31. // stays a sibling of A0, directly under BF31.
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser // EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
// disconnect or reverse-proxy timeout must not cancel it halfway through and // disconnect or reverse-proxy timeout must not cancel it halfway through and
// skip 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) commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
payload, err := channel.es10(commitContext, der) payload, err := channel.es10(commitContext, der)
cancelCommit() cancelCommit()
// A rejected EnableProfile (for example CAT busy) does not emit REFRESH.
// Parse the card-level result before waiting for an indication, otherwise
// every retry needlessly waits for the refresh timeout.
resultBeforeClose, resultPresentBeforeClose := enableProfileResult(payload)
if err == nil && resultPresentBeforeClose && byte(resultBeforeClose) == 5 && nativeQMI {
// Registering CAT2 may immediately deliver a proactive command that was
// already pending before EnableProfile. Drain it on catBusy so the raw
// REFRESH command receives its terminal response before the retry.
catContext, cancelCAT := context.WithTimeout(context.Background(), 3*time.Second)
_ = channel.completeProfileRefresh(catContext)
cancelCAT()
if attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int); attempt == 0 {
recoveryContext, cancelRecovery := context.WithTimeout(context.Background(), 12*time.Second)
_ = channel.recoverCATBusy(recoveryContext)
cancelRecovery()
}
ackContext, cancelAck := context.WithTimeout(context.Background(), 5*time.Second)
_ = channel.acknowledgeProfileRefresh(ackContext)
cancelAck()
}
if err == nil && resultPresentBeforeClose &&
enableProfileResponseError(byte(resultBeforeClose), payload) == nil &&
refreshRequested && nativeQMI {
refreshContext, cancelRefresh := context.WithTimeout(context.Background(), 20*time.Second)
_ = channel.completeProfileRefresh(refreshContext)
cancelRefresh()
}
// Release the logical channel before any reset: openEuicc's csim holds // Release the logical channel before any reset: openEuicc's csim holds
// opMu only for the duration of each APDU, so by here the lock is free. // opMu only for the duration of each APDU, so by here the lock is free.
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout) closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
channel.resetOnClose = channel.pcscSession != nil
channel.close(closeContext) channel.close(closeContext)
cancelClose() cancelClose()
if err != nil { if err != nil {
@@ -588,7 +872,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// detached reset is safe in either case and prevents an uncertain switch // detached reset is safe in either case and prevents an uncertain switch
// from leaving the modem's SIM cache unusable. // from leaving the modem's SIM cache unusable.
manager.startProfileSwitchRecovery(id) manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock() manager.unlockESIM()
return err return err
} }
// A transport SW 9000 only means the APDU reached the eUICC. The real outcome // A transport SW 9000 only means the APDU reached the eUICC. The real outcome
@@ -597,19 +881,57 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
result, ok := enableProfileResult(payload) result, ok := enableProfileResult(payload)
if !ok { if !ok {
manager.startProfileSwitchRecovery(id) manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock() manager.unlockESIM()
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload))) return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
} }
if err := enableProfileResponseError(byte(result), payload); err != nil { if err := enableProfileResponseError(byte(result), payload); err != nil {
manager.esimMu.Unlock() 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 return err
} }
manager.markCachedProfileEnabled(id, iccid) manager.markCachedProfileEnabled(id, iccid)
// EnableProfile already requested an eUICC REFRESH. Some AT modems consume
// that proactive command and expose the new subscription immediately, so a
// full CFUN=1,1 reset would only add downtime. Give those devices a short
// chance to prove that their SIM cache is current; modems that keep reporting
// the old ICCID continue through the established reboot/recovery path below.
if manager.canVerifyProfileSwitchWithoutRestart(id) {
probeContext, cancelProbe := context.WithTimeout(
context.WithoutCancel(ctx),
profileSwitchRefreshProbeTimeout(manager),
)
probeErr := manager.verifySwitchedICCIDAttempts(probeContext, id, iccid, 3, time.Second)
cancelProbe()
if probeErr == nil {
// Repopulate the cached snapshot while the AT transport is still live.
// Verification above is authoritative, so snapshot refresh remains
// best-effort just as it is after the legacy reboot path.
refreshContext, cancelRefresh := context.WithTimeout(
context.WithoutCancel(ctx),
manager.longTimeout,
)
_, _ = manager.Refresh(refreshContext, id)
cancelRefresh()
manager.unlockESIM()
return nil
}
}
// The eUICC accepted the target profile. Reset and repopulate the modem in // The eUICC accepted the target profile. Reset and repopulate the modem in
// a detached recovery so it survives an HTTP disconnect, but keep this API // a detached recovery so it survives an HTTP disconnect, but keep this API
// call pending until the live modem ICCID proves that the switch took effect. // call pending until the live modem ICCID proves that the switch took effect.
manager.startProfileSwitchRecovery(id) manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock() manager.unlockESIM()
verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager)) verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
defer cancelVerify() defer cancelVerify()
@@ -619,6 +941,8 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
return manager.verifySwitchedICCID(verifyContext, id, iccid) return manager.verifySwitchedICCID(verifyContext, id, iccid)
} }
type esimCATBusyRetryKey struct{}
func (manager *Manager) startProfileSwitchRecovery(id string) { func (manager *Manager) startProfileSwitchRecovery(id string) {
done := make(chan struct{}) done := make(chan struct{})
manager.esimRecoveryMu.Lock() manager.esimRecoveryMu.Lock()
@@ -752,8 +1076,22 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
// CFUN=1,1, so the reset error is intentionally followed by discovery retries. // CFUN=1,1, so the reset error is intentionally followed by discovery retries.
func (manager *Manager) recoverAfterProfileSwitch(id string) { func (manager *Manager) recoverAfterProfileSwitch(id string) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout) resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id) 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() cancelReset()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id) manager.refreshAfterProfileSwitch(id)
} }
@@ -766,6 +1104,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
// the next attempt. All errors are swallowed: this is best-effort self-healing // the next attempt. All errors are swallowed: this is best-effort self-healing
// and setResult already records the last failure for the UI. // and setResult already records the last failure for the UI.
func (manager *Manager) refreshAfterProfileSwitch(id string) { func (manager *Manager) refreshAfterProfileSwitch(id string) {
if manager.isPCSCDevice(id) {
time.Sleep(750 * time.Millisecond)
for attempt := 0; attempt < 10; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
}
time.Sleep(time.Second)
}
return
}
const ( const (
settle = 8 * time.Second settle = 8 * time.Second
interval = 4 * time.Second interval = 4 * time.Second
@@ -774,7 +1126,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
time.Sleep(settle) time.Sleep(settle)
for attempt := 0; attempt < attempts; attempt++ { for attempt := 0; attempt < attempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4) ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, err := manager.Refresh(ctx, id) _, _ = manager.Discover(ctx)
_, flightErr := manager.SetFlight(ctx, id, true)
var err error
if flightErr == nil {
_, err = manager.Refresh(ctx, id)
} else {
err = flightErr
}
cancel() cancel()
if err == nil { if err == nil {
return return
@@ -783,6 +1142,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
} }
} }
func (manager *Manager) isPCSCDevice(id string) bool {
state, err := manager.lookup(id)
if err != nil {
return false
}
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
}
// enableProfileResult extracts the EnableProfile result code (tag 80) from the // enableProfileResult extracts the EnableProfile result code (tag 80) from the
// ES10c response body. ok is false when no result code is present. // ES10c response body. ok is false when no result code is present.
func enableProfileResult(payload []byte) (int, bool) { func enableProfileResult(payload []byte) (int, bool) {
@@ -842,39 +1209,103 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
return timeout return timeout
} }
func profileSwitchRefreshProbeTimeout(manager *Manager) time.Duration {
// Allow both standard ICCID commands to consume one ordinary command
// timeout, plus a small window for the eUICC REFRESH to settle. Keep the
// optimisation bounded so an older modem reaches its required reboot soon.
timeout := manager.commandTimeout*2 + time.Second
if timeout < 3*time.Second {
return 3 * time.Second
}
if timeout > 10*time.Second {
return 10 * time.Second
}
return timeout
}
func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
_, native, err := manager.nativeQMIControl(id)
return err == nil && !native && !manager.isPCSCDevice(id)
}
// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c // verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
// result of zero only means the eUICC accepted the operation; the state change // result of zero only means the eUICC accepted the operation; the state change
// is finalized by REFRESH/reset. The UI must not report success until the modem // is finalized by REFRESH/reset. The UI must not report success until the modem
// is actually exposing the requested ICCID. // is actually exposing the requested ICCID.
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error { func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
}
func (manager *Manager) verifySwitchedICCIDAttempts(
ctx context.Context,
id string,
expected string,
attempts int,
interval time.Duration,
) error {
expected = strings.TrimSpace(expected) expected = strings.TrimSpace(expected)
const attempts = 6
var lastICCID string var lastICCID string
var lastErr error var lastErr error
for attempt := 0; attempt < attempts; attempt++ { for attempt := 0; attempt < attempts; attempt++ {
for _, command := range []string{"AT+CCID", "AT+QCCID"} { if control, native, nativeErr := manager.nativeQMIControl(id); native {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout) if nativeErr != nil {
response, err := manager.ExecuteAT(commandContext, id, command) lastErr = nativeErr
cancel() } else {
if err != nil { state, lookupErr := manager.lookup(id)
lastErr = err if lookupErr != nil {
continue 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
}
}
} }
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22) } else if nativeErr != nil {
if live == "" { lastErr = nativeErr
lastErr = errors.New("modem response contained no valid ICCID") } else if manager.isPCSCDevice(id) {
continue snapshot, err := manager.Refresh(ctx, id)
if err == nil {
lastICCID = strings.TrimSpace(snapshot.ICCID)
if lastICCID == expected {
return nil
}
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
} }
lastICCID = live lastErr = err
if live == expected { } else {
return nil for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
if err != nil {
lastErr = err
continue
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
} }
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
} }
if attempt+1 < attempts { if attempt+1 < attempts {
select { select {
case <-time.After(2 * time.Second): case <-time.After(interval):
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err()) return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
} }
+15 -3
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
) )
var ( var (
@@ -68,8 +69,8 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
if err != nil { if err != nil {
return nil, err return nil, err
} }
manager.esimMu.Lock() manager.lockESIM()
defer manager.esimMu.Unlock() defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil { if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err return nil, err
} }
@@ -101,13 +102,24 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
} }
deleted := &EsimDeleteResult{} deleted := &EsimDeleteResult{}
var warnings []string
if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil { if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil {
if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore { if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore {
deleted.SpaceDelta = int64(freeAfter - freeBefore) deleted.SpaceDelta = int64(freeAfter - freeBefore)
} }
} else { } else {
deleted.Warning = "Profile was deleted, but reclaimed storage could not be read" warnings = append(warnings, "Profile 已删除,但无法读取释放的存储空间")
} }
// DeleteProfile creates a signed notification only when the Profile metadata
// configured a receiver. Flush all retained notifications so earlier events
// for the same receiver cannot be overtaken by this delete event.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notifyErr := channel.deliverPendingNotifications(notifyContext)
cancelNotify()
if notifyErr != nil {
warnings = append(warnings, "Profile 已删除,但运营商通知发送失败;通知已保留在 eUICC,可稍后重发")
}
deleted.Warning = strings.Join(warnings, "")
manager.removeCachedProfile(id, strings.TrimSpace(iccid)) manager.removeCachedProfile(id, strings.TrimSpace(iccid))
return deleted, nil return deleted, nil
} }
+2 -2
View File
@@ -63,8 +63,8 @@ func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHe
if err != nil { if err != nil {
return err return err
} }
manager.esimMu.Lock() manager.lockESIM()
defer manager.esimMu.Unlock() defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil { if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err return err
} }
+45 -23
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"time"
) )
// EsimDownloadParams are the SPA download form fields, mapped from the // EsimDownloadParams are the SPA download form fields, mapped from the
@@ -47,8 +48,8 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
} }
} }
manager.esimMu.Lock() manager.lockESIM()
defer manager.esimMu.Unlock() defer manager.unlockESIM()
report("preflight", "正在检查 eUICC 剩余空间...", 10) report("preflight", "正在检查 eUICC 剩余空间...", 10)
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex)) channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex))
@@ -74,7 +75,10 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
return nil, err return nil, err
} }
client := newES9PClient(smdp) client, err := newES9PClient(ctx, smdp)
if err != nil {
return nil, err
}
report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30) report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30)
init, err := client.initiateAuthentication(ctx, challenge, info1) init, err := client.initiateAuthentication(ctx, challenge, info1)
@@ -127,15 +131,24 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
if err != nil { if err != nil {
return nil, err return nil, err
} }
iccid, err := installationResult(installResponse)
if err != nil {
return nil, err
}
report("notify", "正在向运营商发送下载通知...", 90) report("notify", "正在向运营商发送下载通知...", 90)
iccid, installErr := installationResult(installResponse)
warning := "" warning := ""
if err := client.handleNotification(ctx, installResponse); err != nil { notification, notificationErr := parsePendingNotification(installResponse)
warning = "Profile 已安装,但下载通知发送失败" if notificationErr == nil {
// Loading the final BPP segment is the commit point. Finish the operator
// acknowledgement even if the browser closes its SSE connection now.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notificationErr = channel.deliverNotification(notifyContext, notification)
cancelNotify()
}
if notificationErr != nil {
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
}
// Error installation results must be reported too. Return the card-side
// installation failure only after making that best-effort ES9+ attempt.
if installErr != nil {
return nil, installErr
} }
freeAfter := freeBefore freeAfter := freeBefore
@@ -215,19 +228,28 @@ type EsimChipInfo struct {
// ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for // ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for
// the chip header. It takes the eSIM lock like the other card ops. // the chip header. It takes the eSIM lock like the other card ops.
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) { func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
manager.esimMu.Lock() manager.lockESIM()
defer manager.esimMu.Unlock() defer manager.unlockESIM()
channel, err := manager.openEuicc(ctx, id)
if err != nil {
return nil, err
}
defer channel.close(context.Background())
info, err := readEsimChipInfo(ctx, channel, isdRAID) var lastErr error
if err != nil { for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
return nil, err channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
info, err := readEsimChipInfo(ctx, channel, aid)
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
return &info, nil
} }
return &info, nil if lastErr != nil {
return nil, lastErr
}
return nil, ErrNoEUICC
} }
func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) { func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) {
@@ -264,8 +286,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string)
// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo, // the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo,
// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued. // GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued.
func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) { func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) {
manager.esimMu.Lock() manager.lockESIM()
defer manager.esimMu.Unlock() defer manager.unlockESIM()
if manager.esimRecoveryActive(id) { if manager.esimRecoveryActive(id) {
return nil, errESIMRecovering return nil, errESIMRecovering
} }
+9 -1
View File
@@ -52,7 +52,7 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
if err != nil { if err != nil {
return nil, err return nil, err
} }
if sw != 0x9000 { if !es10StatusOK(sw) {
return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw) return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw)
} }
assembled = append(assembled, payload...) assembled = append(assembled, payload...)
@@ -62,6 +62,14 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
return assembled, nil return assembled, nil
} }
// 91xx is a successful UICC result with a proactive SIM Toolkit command
// pending. EnableProfile commonly returns it on direct PC/SC transports because
// the requested refresh is delivered to the terminal rather than consumed by
// modem firmware. Resetting the card after the operation applies that refresh.
func es10StatusOK(sw int) bool {
return sw == 0x9000 || sw>>8 == 0x91
}
// getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes. // getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes.
func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) { func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) {
payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00}) payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00})
+13
View File
@@ -155,3 +155,16 @@ func TestEuiccFreeNVRAM(t *testing.T) {
t.Fatalf("expected ok=false when extCardResource absent") t.Fatalf("expected ok=false when extCardResource absent")
} }
} }
func TestES10StatusAcceptsProactiveRefresh(t *testing.T) {
for _, status := range []int{0x9000, 0x9100, 0x910B, 0x91FF} {
if !es10StatusOK(status) {
t.Fatalf("status %04X should be successful", status)
}
}
for _, status := range []int{0x6A82, 0x6985, 0x9200} {
if es10StatusOK(status) {
t.Fatalf("status %04X should fail", status)
}
}
}
+354
View File
@@ -0,0 +1,354 @@
package device
import (
"context"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
)
// EsimNotification is one notification retained by an eUICC until its receiver
// acknowledges it through ES9+.HandleNotification.
type EsimNotification struct {
SequenceNumber uint64 `json:"sequenceNumber"`
Event string `json:"event,omitempty"`
ICCID string `json:"iccid,omitempty"`
Address string `json:"address,omitempty"`
AIDHex string `json:"aidHex,omitempty"`
CanRetry bool `json:"canRetry"`
raw []byte
}
func encodePositiveInteger(value uint64) []byte {
if value == 0 {
return []byte{0}
}
encoded := make([]byte, 8)
for index := len(encoded) - 1; index >= 0; index-- {
encoded[index] = byte(value & 0xff)
value >>= 8
}
for len(encoded) > 1 && encoded[0] == 0 {
encoded = encoded[1:]
}
if encoded[0]&0x80 != 0 {
encoded = append([]byte{0}, encoded...)
}
return encoded
}
func decodePositiveInteger(encoded []byte) (uint64, bool) {
if len(encoded) == 0 || len(encoded) > 9 || encoded[0]&0x80 != 0 {
return 0, false
}
if len(encoded) == 9 {
if encoded[0] != 0 {
return 0, false
}
encoded = encoded[1:]
}
var value uint64
for _, octet := range encoded {
value = value<<8 | uint64(octet)
}
return value, true
}
func buildRetrieveNotificationsRequest(sequenceNumber *uint64) []byte {
if sequenceNumber == nil {
return derConstruct(0xBF2B)
}
return derConstruct(0xBF2B, derEncode(0x80, encodePositiveInteger(*sequenceNumber)))
}
func buildListNotificationsRequest() []byte {
return derConstruct(0xBF28)
}
func buildRemoveNotificationRequest(sequenceNumber uint64) []byte {
return derConstruct(0xBF30, derEncode(0x80, encodePositiveInteger(sequenceNumber)))
}
func notificationEventName(bitString []byte) string {
if len(bitString) < 2 || bitString[0] > 7 {
return ""
}
bitCount := (len(bitString)-1)*8 - int(bitString[0])
for bit := 0; bit < bitCount; bit++ {
if bitString[1+bit/8]&(0x80>>uint(bit%8)) == 0 {
continue
}
switch bit {
case 0:
return "install"
case 1, 4:
return "enable"
case 2, 5:
return "disable"
case 3, 6:
return "delete"
case 7:
return "rpm"
default:
return fmt.Sprintf("event-%d", bit)
}
}
return ""
}
func notificationFromMetadata(metadata *derNode) (EsimNotification, error) {
sequenceNumber, ok := decodePositiveInteger(derValue(metadata.children, 0x80))
if !ok {
return EsimNotification{}, errors.New("esim: pending notification has an invalid sequence number")
}
address := strings.TrimSpace(string(derValue(metadata.children, 0x0C)))
if address == "" {
return EsimNotification{}, errors.New("esim: pending notification has no receiver address")
}
return EsimNotification{
SequenceNumber: sequenceNumber,
Event: notificationEventName(derValue(metadata.children, 0x81)),
ICCID: decodeICCID(derValue(metadata.children, 0x5A)),
Address: address,
CanRetry: true,
}, nil
}
func parsePendingNotification(raw []byte) (EsimNotification, error) {
metadataNodes := derFindAll(derParse(raw), 0xBF2F)
if len(metadataNodes) == 0 {
return EsimNotification{}, errors.New("esim: pending notification has no metadata")
}
notification, err := notificationFromMetadata(metadataNodes[0])
if err != nil {
return EsimNotification{}, err
}
notification.raw = append([]byte(nil), raw...)
return notification, nil
}
func parseNotificationMetadataList(payload []byte) ([]EsimNotification, error) {
tag, headerLength, totalLength, err := derElementAt(payload, 0)
if err != nil || tag != 0xBF28 || totalLength != len(payload) {
return nil, fmt.Errorf("esim: unexpected ListNotification response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
value := payload[headerLength:totalLength]
responseNodes := derParse(value)
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
return nil, fmt.Errorf("esim: eUICC could not list notifications (result %X)", responseNodes[0].value)
}
metadataNodes := derFindAll(responseNodes, 0xBF2F)
notifications := make([]EsimNotification, 0, len(metadataNodes))
for _, metadata := range metadataNodes {
notification, parseErr := notificationFromMetadata(metadata)
if parseErr != nil {
return nil, parseErr
}
notifications = append(notifications, notification)
}
sort.SliceStable(notifications, func(left, right int) bool {
if notifications[left].Address == notifications[right].Address {
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
}
return notifications[left].Address < notifications[right].Address
})
return notifications, nil
}
func parsePendingNotifications(payload []byte) ([]EsimNotification, error) {
tag, headerLength, totalLength, err := derElementAt(payload, 0)
if err != nil || tag != 0xBF2B || totalLength != len(payload) {
return nil, fmt.Errorf("esim: unexpected RetrieveNotificationsList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
value := payload[headerLength:totalLength]
responseNodes := derParse(value)
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
errorCode := responseNodes[0].value
return nil, fmt.Errorf("esim: eUICC could not retrieve notifications (result %X)", errorCode)
}
// The notificationList CHOICE alternative is encoded as context tag A0 by
// AUTOMATIC TAGS on newer eUICCs. Older cards are also seen returning the
// SEQUENCE OF contents directly. Accept both without including the list
// wrapper in the PendingNotification sent to ES9+.
if len(responseNodes) == 1 && responseNodes[0].tag == 0xA0 {
value = responseNodes[0].value
} else if len(responseNodes) == 1 && responseNodes[0].tag == 0x30 && firstChild(responseNodes[0].children, 0xBF2F) == nil {
value = responseNodes[0].value
}
var notifications []EsimNotification
for offset := 0; offset < len(value); {
_, _, elementLength, elementErr := derElementAt(value, offset)
if elementErr != nil {
return nil, elementErr
}
raw := value[offset : offset+elementLength]
notification, parseErr := parsePendingNotification(raw)
if parseErr != nil {
return nil, parseErr
}
notifications = append(notifications, notification)
offset += elementLength
}
sort.SliceStable(notifications, func(left, right int) bool {
if notifications[left].Address == notifications[right].Address {
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
}
return notifications[left].Address < notifications[right].Address
})
return notifications, nil
}
func removeNotificationResult(payload []byte) error {
roots := derParse(payload)
if len(roots) != 1 || roots[0].tag != 0xBF30 {
return fmt.Errorf("esim: unexpected RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
result := derValue(roots[0].children, 0x80)
if len(result) == 0 {
result = derValue(roots[0].children, 0x02)
}
if len(result) != 1 {
return fmt.Errorf("esim: malformed RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
switch result[0] {
case 0, 1: // ok, or already removed after an earlier acknowledged retry
return nil
default:
return fmt.Errorf("esim: eUICC could not remove notification (result %d)", result[0])
}
}
func (channel *euiccChannel) retrieveNotifications(ctx context.Context, sequenceNumber *uint64) ([]EsimNotification, error) {
payload, err := channel.es10(ctx, buildRetrieveNotificationsRequest(sequenceNumber))
if err != nil {
return nil, err
}
return parsePendingNotifications(payload)
}
func (channel *euiccChannel) listNotifications(ctx context.Context) ([]EsimNotification, error) {
payload, err := channel.es10(ctx, buildListNotificationsRequest())
if err != nil {
return nil, err
}
return parseNotificationMetadataList(payload)
}
func (channel *euiccChannel) removeNotification(ctx context.Context, sequenceNumber uint64) error {
payload, err := channel.es10(ctx, buildRemoveNotificationRequest(sequenceNumber))
if err != nil {
return err
}
return removeNotificationResult(payload)
}
func (channel *euiccChannel) deliverNotification(ctx context.Context, notification EsimNotification) error {
client, err := newES9PClient(ctx, notification.Address)
if err != nil {
return err
}
if err := client.handleNotification(ctx, notification.raw); err != nil {
return err
}
if err := channel.removeNotification(ctx, notification.SequenceNumber); err != nil {
return fmt.Errorf("notification acknowledged but could not be removed from eUICC: %w", err)
}
return nil
}
// deliverPendingNotifications sends each receiver's notifications oldest first.
// A failed item stops only that receiver's group so a later sequence number can
// never overtake it and make the older notification stale.
func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) error {
notifications, err := channel.listNotifications(ctx)
if err != nil {
return err
}
blockedAddresses := make(map[string]bool)
var failures []error
for _, notification := range notifications {
if blockedAddresses[notification.Address] {
continue
}
pending, retrieveErr := channel.retrieveNotifications(ctx, &notification.SequenceNumber)
if retrieveErr == nil {
retrieveErr = fmt.Errorf("esim: notification %d was not returned by eUICC", notification.SequenceNumber)
for _, candidate := range pending {
if candidate.SequenceNumber == notification.SequenceNumber {
retrieveErr = channel.deliverNotification(ctx, candidate)
break
}
}
}
if retrieveErr != nil {
blockedAddresses[notification.Address] = true
failures = append(failures, fmt.Errorf("notification %d to %s: %w", notification.SequenceNumber, notification.Address, retrieveErr))
}
}
return errors.Join(failures...)
}
// ESIMNotifications returns the notifications retained across every eUICC
// storage exposed by the physical card.
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
var all []EsimNotification
var lastErr error
succeeded := false
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
notifications, retrieveErr := channel.listNotifications(ctx)
channel.close(context.Background())
if retrieveErr != nil {
lastErr = retrieveErr
continue
}
succeeded = true
for index := range notifications {
notifications[index].AIDHex = aid
}
all = append(all, notifications...)
}
if !succeeded && lastErr != nil {
return nil, lastErr
}
return all, nil
}
// ESIMRetryNotification sends one retained notification and removes it from the
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil {
return err
}
defer channel.close(context.Background())
notifications, err := channel.retrieveNotifications(ctx, &sequenceNumber)
if err != nil {
return err
}
for _, notification := range notifications {
if notification.SequenceNumber == sequenceNumber {
return channel.deliverNotification(ctx, notification)
}
}
return fmt.Errorf("esim: notification %d was not found", sequenceNumber)
}
@@ -0,0 +1,96 @@
package device
import (
"bytes"
"testing"
)
func TestPositiveIntegerEncodingRoundTripsFullUint64Range(t *testing.T) {
for _, value := range []uint64{0, 1, 127, 128, 255, 256, ^uint64(0)} {
encoded := encodePositiveInteger(value)
decoded, ok := decodePositiveInteger(encoded)
if !ok || decoded != value {
t.Errorf("round trip %d: encoded=%X decoded=%d ok=%t", value, encoded, decoded, ok)
}
}
}
func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address, iccid string) []byte {
t.Helper()
iccidBCD, err := encodeICCID(iccid)
if err != nil {
t.Fatal(err)
}
return derConstruct(0xBF2F,
derEncode(0x80, []byte{sequence}),
derEncode(0x81, event),
derEncode(0x0C, []byte(address)),
derEncode(0x5A, iccidBCD),
)
}
func TestParsePendingNotifications(t *testing.T) {
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
if err != nil {
t.Fatalf("parsePendingNotifications: %v", err)
}
if len(notifications) != 2 {
t.Fatalf("notifications = %#v", notifications)
}
// Results are grouped by receiver, then sorted by sequence number.
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
}
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
}
metadata, err := parseNotificationMetadataList(derConstruct(0xBF28, derConstruct(0xA0, installMetadata, deleteMetadata)))
if err != nil || len(metadata) != 2 {
t.Fatalf("parseNotificationMetadataList = %#v, %v", metadata, err)
}
if metadata[0].SequenceNumber != 9 || metadata[0].Event != "delete" || len(metadata[0].raw) != 0 {
t.Fatalf("listed metadata = %#v", metadata[0])
}
}
func TestNotificationRequestsAndRemoveResult(t *testing.T) {
if got := buildListNotificationsRequest(); !bytes.Equal(got, []byte{0xBF, 0x28, 0x00}) {
t.Fatalf("list request = %X", got)
}
if got := buildRetrieveNotificationsRequest(nil); !bytes.Equal(got, []byte{0xBF, 0x2B, 0x00}) {
t.Fatalf("retrieve all request = %X", got)
}
sequenceNumber := uint64(128)
wantRetrieve := []byte{0xBF, 0x2B, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRetrieveNotificationsRequest(&sequenceNumber); !bytes.Equal(got, wantRetrieve) {
t.Fatalf("retrieve request = %X, want %X", got, wantRetrieve)
}
wantRemove := []byte{0xBF, 0x30, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRemoveNotificationRequest(sequenceNumber); !bytes.Equal(got, wantRemove) {
t.Fatalf("remove request = %X, want %X", got, wantRemove)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x00}); err != nil {
t.Fatalf("removeNotificationResult(ok): %v", err)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x7F}); err == nil {
t.Fatal("undefinedError response was accepted")
}
}
func TestParsePendingNotificationsRejectsMalformedMetadata(t *testing.T) {
missingAddress := derConstruct(0x30, derConstruct(0xBF2F,
derEncode(0x80, []byte{1}),
derEncode(0x81, []byte{4, 0x10}),
))
if _, err := parsePendingNotifications(derConstruct(0xBF2B, missingAddress)); err == nil {
t.Fatal("notification without receiver address was accepted")
}
}
+2 -2
View File
@@ -49,8 +49,8 @@ func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickna
if err != nil { if err != nil {
return err return err
} }
manager.esimMu.Lock() manager.lockESIM()
defer manager.esimMu.Unlock() defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil { if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err return err
} }
+147
View File
@@ -1,6 +1,7 @@
package device package device
import ( import (
"bytes"
"context" "context"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -206,6 +207,38 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
client.assertDone(t) client.assertDone(t)
} }
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
const target = "89492026266006792824"
client := &transcriptClient{steps: []clientStep{
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
}}
manager, id := newStartedTestManager(t, client)
if !manager.canVerifyProfileSwitchWithoutRestart(id) {
t.Fatal("AT modem should be eligible for refresh verification before restart")
}
if err := manager.verifySwitchedICCIDAttempts(context.Background(), id, target, 2, 0); err != nil {
t.Fatalf("verifySwitchedICCIDAttempts: %v", err)
}
client.assertDone(t)
}
func TestProfileSwitchRefreshProbeTimeoutIsBounded(t *testing.T) {
for _, test := range []struct {
command time.Duration
want time.Duration
}{
{command: 100 * time.Millisecond, want: 3 * time.Second},
{command: 3 * time.Second, want: 7 * time.Second},
{command: 30 * time.Second, want: 10 * time.Second},
} {
manager := &Manager{commandTimeout: test.command}
if got := profileSwitchRefreshProbeTimeout(manager); got != test.want {
t.Fatalf("command timeout %s: probe timeout = %s, want %s", test.command, got, test.want)
}
}
}
func TestEUMManufacturerForWatchData(t *testing.T) { func TestEUMManufacturerForWatchData(t *testing.T) {
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." { if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
t.Fatalf("manufacturer = %q", got) t.Fatalf("manufacturer = %q", got)
@@ -244,6 +277,80 @@ func TestTransientEuiccCMEClassification(t *testing.T) {
} }
} }
func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
manageChannel := clientStep{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"019000"`),
}
closeChannel := clientStep{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
}
selectStep := func(aid, response string) clientStep {
return clientStep{
command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid),
response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, response)),
}
}
client := &transcriptClient{steps: []clientStep{
// No eSTK product applet on this card.
manageChannel,
selectStep(estkProductAID, "6A82"),
closeChannel,
// XeSIM does not expose the standard GSMA ...0100 application.
manageChannel,
selectStep(isdRAID, "6A82"),
closeChannel,
// Its dedicated ...0177 ISD-R is selectable.
manageChannel,
selectStep(xesimISDRAID, "9000"),
closeChannel,
}}
manager, id := newStartedTestManager(t, client)
aids := manager.discoverEuiccAIDs(context.Background(), id)
if len(aids) != 1 || aids[0] != xesimISDRAID {
t.Fatalf("discovered AIDs = %#v, want XeSIM %s", aids, xesimISDRAID)
}
client.assertDone(t)
}
func TestNativeQMIUsesUIMLogicalChannelForEUICC(t *testing.T) {
manager, _, id := newStartedNativeQMITestManager(t)
if err := manager.SetBackend(id, "qmi"); err != nil {
t.Fatal(err)
}
session := &fakeQMIRadioSession{
openChannel: 3,
apduResponse: []byte{0xDE, 0xAD, 0x90, 0x00},
}
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
return session, nil
}
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
if err != nil {
t.Fatalf("open QMI eUICC: %v", err)
}
payload, sw, err := channel.transmit(context.Background(), []byte{0x80, 0xCA, 0x00, 0x00, 0x00}, 0x80)
if err != nil {
t.Fatalf("transmit QMI APDU: %v", err)
}
if !bytes.Equal(payload, []byte{0xDE, 0xAD}) || sw != 0x9000 {
t.Fatalf("QMI APDU response = %X/%04X", payload, sw)
}
channel.close(context.Background())
if len(session.openedAIDs) != 1 || strings.ToUpper(hex.EncodeToString(session.openedAIDs[0])) != isdRAID {
t.Fatalf("opened AIDs = %X", session.openedAIDs)
}
if len(session.apdus) != 1 || session.apdus[0][0] != 0x83 {
t.Fatalf("QMI APDUs = %X", session.apdus)
}
if len(session.closedChannels) != 1 || session.closedChannels[0] != 3 || session.closeCount != 1 {
t.Fatalf("closed channels/session = %v/%d", session.closedChannels, session.closeCount)
}
}
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) { func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
cause := &modem.CommandError{ cause := &modem.CommandError{
Command: `AT+CSIM=10,"0070000001"`, Command: `AT+CSIM=10,"0070000001"`,
@@ -255,6 +362,46 @@ func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
} }
} }
func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"006A81"`),
},
{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
},
{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"019000"`),
},
{
command: fmt.Sprintf(
`AT+CSIM=42,"01A4040010%s"`,
isdRAID,
),
response: okResponse(`+CSIM: 4,"9000"`),
},
{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
},
}}
manager, id := newStartedTestManager(t, client)
manager.lockESIM()
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
if err == nil {
channel.close(context.Background())
}
manager.unlockESIM()
if err != nil {
t.Fatalf("open eUICC after orphaned channel: %v", err)
}
client.assertDone(t)
}
func TestWaitForESIMRecovery(t *testing.T) { func TestWaitForESIMRecovery(t *testing.T) {
done := make(chan struct{}) done := make(chan struct{})
manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}} manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}}
+813
View File
@@ -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
}
}
+52
View File
@@ -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")
}
}
+63
View File
@@ -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
}
+84
View File
@@ -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
}
+68
View File
@@ -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)
}
+196 -9
View File
@@ -4,12 +4,14 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"vocat/internal/modem" "vocat/internal/modem"
"vocat/internal/pcsc"
) )
type Options struct { type Options struct {
@@ -19,10 +21,13 @@ type Options struct {
LongTimeout time.Duration LongTimeout time.Duration
SMSTimeout time.Duration SMSTimeout time.Duration
ScanTimeout time.Duration ScanTimeout time.Duration
CardReaders *pcsc.Service
Logger *slog.Logger
} }
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions
esimMu sync.Mutex // serializes eSIM card access (list/switch/download) esimMu sync.Mutex // serializes eSIM card access (list/switch/download)
esimRecoveryMu sync.Mutex esimRecoveryMu sync.Mutex
esimRecoveries map[string]chan struct{} esimRecoveries map[string]chan struct{}
@@ -34,9 +39,33 @@ type Manager struct {
longTimeout time.Duration longTimeout time.Duration
smsTimeout time.Duration smsTimeout time.Duration
scanTimeout time.Duration scanTimeout time.Duration
started bool cardReaders *pcsc.Service
devices map[string]*managedDevice logger *slog.Logger
ussdSessions map[string]ussdSession
qmiRadioOpener qmiRadioSessionOpener
nativeQMIRegistrationMu sync.Mutex
nativeQMIRegistrationInFlight map[string]struct{}
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
}
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10.
// Individual AT commands are already serialized per modem, but a logical-
// channel transaction spans several commands and must not be interleaved.
func (manager *Manager) LockUICC() { manager.uiccMu.Lock() }
func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() }
func (manager *Manager) lockESIM() {
manager.esimMu.Lock()
manager.uiccMu.Lock()
}
func (manager *Manager) unlockESIM() {
manager.uiccMu.Unlock()
manager.esimMu.Unlock()
} }
// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or // ussdSession tracks an open USSD dialog on a device so a follow-up Continue or
@@ -50,6 +79,8 @@ type ussdSession struct {
type managedDevice struct { type managedDevice struct {
opMu sync.Mutex opMu sync.Mutex
candidate modem.Candidate candidate modem.Candidate
backend string
lastICCID string
client modem.Client client modem.Client
snapshot *Snapshot snapshot *Snapshot
lastError string lastError string
@@ -57,6 +88,7 @@ type managedDevice struct {
discovered bool discovered bool
preFlightMode *int preFlightMode *int
resetClientOnLock bool resetClientOnLock bool
simPIN string
} }
func NewManager(options Options) (*Manager, error) { func NewManager(options Options) (*Manager, error) {
@@ -80,6 +112,9 @@ func NewManager(options Options) (*Manager, error) {
// AT+COPS=? can take well over a minute while the modem sweeps every band. // AT+COPS=? can take well over a minute while the modem sweeps every band.
options.ScanTimeout = 150 * time.Second options.ScanTimeout = 150 * time.Second
} }
if options.CardReaders == nil {
options.CardReaders = pcsc.New()
}
return &Manager{ return &Manager{
discoverer: options.Discoverer, discoverer: options.Discoverer,
opener: options.Opener, opener: options.Opener,
@@ -87,6 +122,12 @@ func NewManager(options Options) (*Manager, error) {
longTimeout: options.LongTimeout, longTimeout: options.LongTimeout,
smsTimeout: options.SMSTimeout, smsTimeout: options.SMSTimeout,
scanTimeout: options.ScanTimeout, scanTimeout: options.ScanTimeout,
cardReaders: options.CardReaders,
logger: options.Logger,
qmiRadioOpener: openQMIRadioSession,
nativeQMIRegistrationInFlight: make(map[string]struct{}),
devices: make(map[string]*managedDevice), devices: make(map[string]*managedDevice),
ussdSessions: make(map[string]ussdSession), ussdSessions: make(map[string]ussdSession),
esimRecoveries: make(map[string]chan struct{}), esimRecoveries: make(map[string]chan struct{}),
@@ -144,9 +185,24 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
candidates, err := manager.discoverer.Discover(ctx) candidates, modemErr := manager.discoverer.Discover(ctx)
if err != nil { readers, readerErr := manager.cardReaders.Readers(ctx)
return nil, err if readerErr == nil {
for _, reader := range readers {
candidates = append(candidates, modem.Candidate{
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
ReaderName: reader.Name, USBPath: reader.USBPath,
VendorID: reader.VendorID, ProductID: reader.ProductID,
Manufacturer: reader.Manufacturer, Product: reader.Product,
DiscoveryIssue: reader.DiscoveryIssue,
})
}
}
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
return nil, errors.Join(modemErr, readerErr)
}
if modemErr != nil && len(candidates) == 0 {
return nil, modemErr
} }
seen := make(map[string]struct{}, len(candidates)) seen := make(map[string]struct{}, len(candidates))
@@ -189,7 +245,20 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
state.opMu.Unlock() state.opMu.Unlock()
} }
manager.resetChangedClients() manager.resetChangedClients()
return manager.List(), nil
// List retains previously discovered devices so configured hardware can be
// rendered as offline after it is unplugged. Discover, however, is a fresh
// physical scan and must only return devices that are present now. Returning
// the retained entries here allowed an unplugged modem to be selected and
// added again from the device discovery screen.
devices := manager.List()
present := devices[:0]
for _, entry := range devices {
if entry.Discovered {
present = append(present, entry)
}
}
return present, nil
} }
func (manager *Manager) resetChangedClients() { func (manager *Manager) resetChangedClients() {
@@ -308,10 +377,11 @@ func (manager *Manager) setResult(
err error, err error,
) { ) {
manager.mu.Lock() manager.mu.Lock()
defer manager.mu.Unlock()
if manager.devices[id] != state { if manager.devices[id] != state {
manager.mu.Unlock()
return return
} }
previousError := state.lastError
if snapshot != nil { if snapshot != nil {
value := *snapshot value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...) value.Warnings = append([]string(nil), snapshot.Warnings...)
@@ -323,6 +393,19 @@ func (manager *Manager) setResult(
} else { } else {
state.lastError = "" state.lastError = ""
} }
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
backend := state.backend
hardwareKind := state.candidate.HardwareKind
manager.mu.Unlock()
if shouldLog {
manager.logger.Warn(
"hardware operation failed",
"device_id", id,
"backend", backend,
"hardware_kind", hardwareKind,
"error", HardwareErrorDetail(err),
)
}
} }
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate { func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
@@ -358,16 +441,103 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err return Snapshot{}, err
} }
candidate := manager.candidateFor(state) candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.refreshCardReader(ctx, id, state, candidate)
}
backend := manager.backendFor(state)
client, err := manager.clientLocked(ctx, state, candidate) client, err := manager.clientLocked(ctx, state, candidate)
if err != nil { if err != nil {
manager.setResult(id, state, nil, err) manager.setResult(id, state, nil, err)
return Snapshot{}, err return Snapshot{}, err
} }
snapshot, err := manager.readSnapshot(ctx, id, candidate, client) previousICCID := state.lastICCID
var previousSnapshot *Snapshot
manager.mu.RLock()
if state.snapshot != nil {
copy := *state.snapshot
previousSnapshot = &copy
}
manager.mu.RUnlock()
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, previousSnapshot, client)
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
}
manager.setResult(id, state, &snapshot, err) manager.setResult(id, state, &snapshot, err)
return snapshot, err return snapshot, err
} }
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
result := Snapshot{
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
}
previousICCID := state.lastICCID
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
if err != nil {
switch {
case errors.Is(err, pcsc.ErrNoCard):
result.SIMStatus = ""
err = nil
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
result.SIMStatus = "SIM PIN"
result.Warnings = []string{err.Error()}
err = nil
default:
manager.setResult(id, state, &result, err)
return result, err
}
} else {
result.SIMStatus = "READY"
result.SIMReady = true
result.ICCID = card.Identity.ICCID
result.IMSI = card.Identity.IMSI
result.SPN = card.Identity.SPN
result.MNCLength = card.Identity.MNCLength
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
state.lastICCID = result.ICCID
}
manager.setResult(id, state, &result, err)
return result, err
}
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
// It is deliberately never retained in runtime snapshots or logs.
func (manager *Manager) SetSIMPin(id, pin string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.simPIN = strings.TrimSpace(pin)
return nil
}
// SetBackend selects which control plane supplies registration and data state.
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" && backend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.backend = backend
return nil
}
func (manager *Manager) backendFor(state *managedDevice) string {
manager.mu.RLock()
defer manager.mu.RUnlock()
return state.backend
}
func (manager *Manager) ExecuteAT( func (manager *Manager) ExecuteAT(
ctx context.Context, ctx context.Context,
id string, id string,
@@ -529,3 +699,20 @@ func (manager *Manager) command(
} }
return response, nil return response, nil
} }
// sensitiveCommand executes an AT command containing credentials or other
// authentication material. Modem errors commonly echo the complete command,
// so neither the returned error nor the retained device state may wrap it.
func (manager *Manager) sensitiveCommand(
ctx context.Context,
client modem.Client,
command string,
) (modem.Response, error) {
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
response, err := client.Execute(commandCtx, command)
if err != nil {
return response, errors.New("sensitive modem command failed")
}
return response, nil
}
+201 -9
View File
@@ -6,8 +6,68 @@ import (
"testing" "testing"
"vocat/internal/modem" "vocat/internal/modem"
"vocat/internal/pcsc"
) )
type testPCSCBackend struct{ readers []pcsc.Reader }
func (backend testPCSCBackend) Readers(context.Context) ([]pcsc.Reader, error) {
return append([]pcsc.Reader(nil), backend.readers...), nil
}
func (testPCSCBackend) Open(context.Context, pcsc.Selector) (pcsc.Card, error) {
return nil, pcsc.ErrNoCard
}
func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{}, Opener: &staticOpener{},
CardReaders: pcsc.NewWithBackend(testPCSCBackend{readers: []pcsc.Reader{{
Name: "Alcor Link AK9563 00 00", USBPath: "1-3", Product: "AK9563",
}}}),
})
if err != nil {
t.Fatal(err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
items := manager.List()
if len(items) != 1 || items[0].Candidate.HardwareKind != pcsc.HardwareKind || items[0].Candidate.HasATPort() {
t.Fatalf("discovered readers = %#v", items)
}
snapshot, err := manager.Refresh(context.Background(), items[0].ID)
if err != nil {
t.Fatal(err)
}
if !snapshot.Responsive || snapshot.SIMReady || snapshot.SIMStatus != "" || !snapshot.FlightMode {
t.Fatalf("reader snapshot = %#v", snapshot)
}
}
func TestManagerDiscoverReturnsOnlyCurrentlyPresentDevices(t *testing.T) {
manager, id := newStartedTestManager(t, nil)
if devices := manager.List(); len(devices) != 1 || devices[0].ID != id || !devices[0].Discovered {
t.Fatalf("initial devices = %#v", devices)
}
manager.discoverer = staticDiscoverer{}
present, err := manager.Discover(context.Background())
if err != nil {
t.Fatalf("Discover after unplug: %v", err)
}
if len(present) != 0 {
t.Fatalf("present devices after unplug = %#v, want none", present)
}
// The retained entry is still available to the configured-device dashboard,
// but is explicitly offline and cannot be offered by fresh discovery.
retained := manager.List()
if len(retained) != 1 || retained[0].ID != id || retained[0].Discovered {
t.Fatalf("retained devices after unplug = %#v", retained)
}
}
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{ client := &transcriptClient{steps: []clientStep{
{ {
@@ -19,6 +79,20 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
), ),
}, },
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")}, {command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
{
command: "AT+CCID",
response: modem.Response{Final: "+CME ERROR: 100"},
err: errors.New("CCID unsupported"),
},
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020002FFFF"`)},
{command: "AT+CRSM=176,28478,0,0,2", response: okResponse(`+CRSM: 144,0,"0102"`)},
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")}, {command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
{ {
command: `AT+QENG="servingcell"`, command: `AT+QENG="servingcell"`,
@@ -27,14 +101,8 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
), ),
}, },
{command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)}, {command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)},
{command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)},
{command: "AT+CGSN", response: okResponse("867123456789012")}, {command: "AT+CGSN", response: okResponse("867123456789012")},
{
command: "AT+CCID",
response: modem.Response{Final: "+CME ERROR: 100"},
err: errors.New("CCID unsupported"),
},
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, {command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)}, {command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
}} }}
@@ -66,12 +134,15 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
t.Fatalf("signal metrics = %#v", snapshot) t.Fatalf("signal metrics = %#v", snapshot)
} }
if snapshot.AccessTech != "LTE" || snapshot.Band != "B3" || if snapshot.AccessTech != "LTE" || snapshot.Band != "B3" ||
snapshot.Channel != "1650" || snapshot.OperatorName != "China Mobile" { snapshot.Channel != "1650" || snapshot.OperatorName != "China Unicom" ||
snapshot.OperatorCode != "46001" ||
snapshot.RegistrationStatus != 5 || snapshot.RegistrationSource != "CEREG" {
t.Fatalf("network = %#v", snapshot) t.Fatalf("network = %#v", snapshot)
} }
if snapshot.IMEI != "867123456789012" || if snapshot.IMEI != "867123456789012" ||
snapshot.ICCID != "8986001234567890123" || snapshot.ICCID != "8986001234567890123" ||
snapshot.IMSI != "460001234567890" { snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" ||
snapshot.MNCLength != 2 || snapshot.GID1 != "0102" || snapshot.GID2 != "" {
t.Fatalf("subscriber identifiers = %#v", snapshot) t.Fatalf("subscriber identifiers = %#v", snapshot)
} }
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 || if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
@@ -93,6 +164,70 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client.assertDone(t) client.assertDone(t)
} }
func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "ATI", response: okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410")},
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
{command: "AT+CCID", response: modem.Response{Final: "ERROR"}, err: errors.New("CCID unsupported")},
{command: "AT+QCCID", response: modem.Response{Final: "ERROR"}, err: errors.New("QCCID unsupported")},
{command: "AT+CIMI", response: okResponse("234159611274418")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 106,130,""`)},
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 106,130,""`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,2")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
}}
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
ID: "mhi-wwan0",
Product: "PCIe/MHI WWAN modem",
QMIControl: "/dev/wwan0qmi0",
NetworkInterface: "wwan0",
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
}}},
Opener: &staticOpener{client: client},
})
if err != nil {
t.Fatal(err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
return &fakeQMIRadioSession{iccid: "89441000400316034372", imei: "861716070416510"}, nil
}
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
t.Fatal(err)
}
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if snapshot.ICCID != "89441000400316034372" || snapshot.IMEI != "861716070416510" || !snapshot.SIMReady {
t.Fatalf("native QMI identity = %#v", snapshot)
}
client.assertDone(t)
}
func TestParseSPNASCIIAndUCS2(t *testing.T) {
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
t.Fatalf("ASCII SPN = %q", got)
}
if got := parseSPN(okResponse(`+CRSM: 144,0,"0080004C00650062006100720061FFFF"`)); got != "Lebara" {
t.Fatalf("UCS2 SPN = %q", got)
}
if got := parseSPN(okResponse(`+CRSM: 106,130,""`)); got != "" {
t.Fatalf("failed CRSM SPN = %q", got)
}
}
func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) { func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) {
response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}} response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}}
if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" { if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" {
@@ -119,6 +254,63 @@ func TestManagerRequiresStartAndKnownDevice(t *testing.T) {
} }
} }
func TestManagerBackendSelectionIsExplicit(t *testing.T) {
manager, id := newStartedTestManager(t, &transcriptClient{})
if err := manager.SetBackend(id, "qmi"); err != nil {
t.Fatal(err)
}
state, err := manager.lookup(id)
if err != nil {
t.Fatal(err)
}
if got := manager.backendFor(state); got != "qmi" {
t.Fatalf("backend = %q, want qmi", got)
}
if err := manager.SetBackend(id, "mbim"); err == nil {
t.Fatal("unsupported backend was accepted")
}
}
func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")},
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
{command: "AT+CCID", response: okResponse("+CCID: 8900000000000000002")},
// This must precede CIMI, signal, serving-cell and operator queries.
{command: "AT+CFUN=4", response: okResponse()},
{command: "AT+CIMI", response: okResponse("234150000000002")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28478,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,0")},
{command: "AT+CGSN", response: okResponse("867123456789012")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 4")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+447700900002",145`)},
}}
manager, id := newStartedTestManager(t, client)
state, err := manager.lookup(id)
if err != nil {
t.Fatal(err)
}
state.lastICCID = "8900000000000000001"
snapshot, err := manager.Refresh(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if !snapshot.SIMChanged || !snapshot.FlightMode || snapshot.OperatingMode != 4 {
t.Fatalf("changed SIM snapshot = %#v", snapshot)
}
client.assertDone(t)
}
func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) { func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) {
const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"` const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"`
client := &transcriptClient{steps: []clientStep{{ client := &transcriptClient{steps: []clientStep{{
File diff suppressed because one or more lines are too long
+27 -2
View File
@@ -22,19 +22,44 @@ var BlockedMCCs = map[string]string{
// code. The MCC is the leading three digits and the MNC the following two or // code. The MCC is the leading three digits and the MNC the following two or
// three. Empty strings are returned for an unusable IMSI. // three. Empty strings are returned for an unusable IMSI.
func CardMCCMNC(imsi string) (mcc string, mnc string) { func CardMCCMNC(imsi string) (mcc string, mnc string) {
return CardMCCMNCWithLength(imsi, 0)
}
// CardMCCMNCWithLength uses the MNC length advertised by EF_AD when available.
// Without it the historical three-digit behavior is retained for callers that
// have only an IMSI.
func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
digits := strings.TrimSpace(imsi) digits := strings.TrimSpace(imsi)
if len(digits) < 5 || if len(digits) < 5 ||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 { strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
return "", "" return "", ""
} }
if IsPlaceholderIMSI(digits) {
return "", ""
}
mcc = digits[:3] mcc = digits[:3]
mnc = digits[3:] mnc = digits[3:]
if len(mnc) > 3 { if mncLength != 2 && mncLength != 3 {
mnc = mnc[:3] mncLength = 3
}
if len(mnc) > mncLength {
mnc = mnc[:mncLength]
} }
return mcc, mnc return mcc, mnc
} }
// IsPlaceholderIMSI recognizes an unprovisioned/test identity structurally,
// without tying the decision to a vendor-specific hard-coded ICCID. A valid
// subscriber identity cannot consist of an MCC followed only by zeroes; white
// cards commonly ship in exactly that state before a real profile is enabled.
func IsPlaceholderIMSI(imsi string) bool {
digits := strings.TrimSpace(imsi)
if len(digits) < 10 || strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
return false
}
return strings.Trim(digits[3:], "0") == ""
}
// RegionBlockReason returns a human-readable reason when the SIM identified by // RegionBlockReason returns a human-readable reason when the SIM identified by
// the IMSI belongs to a blocked region. It returns an empty string when the // the IMSI belongs to a blocked region. It returns an empty string when the
// card is allowed or when the IMSI is unavailable: only a confirmed blocked // card is allowed or when the IMSI is unavailable: only a confirmed blocked
+19
View File
@@ -23,6 +23,9 @@ func TestCardMCCMNC(t *testing.T) {
if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" { if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" {
t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc) t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc)
} }
if mcc, mnc := CardMCCMNCWithLength("454006395879502", 2); mcc != "454" || mnc != "00" {
t.Fatalf("CardMCCMNCWithLength = (%q, %q), want (454, 00)", mcc, mnc)
}
for _, bad := range []string{"", "4600", "4600X1234"} { for _, bad := range []string{"", "4600", "4600X1234"} {
if mcc, _ := CardMCCMNC(bad); mcc != "" { if mcc, _ := CardMCCMNC(bad); mcc != "" {
t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc) t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc)
@@ -30,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) { func TestRegionBlockReason(t *testing.T) {
t.Parallel() t.Parallel()
for _, imsi := range []string{"460001234567890", "461001234567890"} { for _, imsi := range []string{"460001234567890", "461001234567890"} {
+36
View File
@@ -0,0 +1,36 @@
//go:build linux
package device
import (
"context"
"os/exec"
"strings"
"time"
"vocat/internal/modem"
)
func readPlatformRegistration(ctx context.Context, candidate modem.Candidate) (platformRegistration, bool) {
control := strings.TrimSpace(candidate.QMIControl)
if control == "" {
return platformRegistration{}, false
}
qmicli, err := exec.LookPath("qmicli")
if err != nil {
return platformRegistration{}, false
}
queryContext, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
output, err := exec.CommandContext(
queryContext,
qmicli,
"-d", control,
"--device-open-proxy",
"--nas-get-serving-system",
).CombinedOutput()
if err != nil {
return platformRegistration{}, false
}
return parseQMIRegistration(string(output))
}
+702
View File
@@ -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)
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package device
import (
"context"
"vocat/internal/modem"
)
func readPlatformRegistration(context.Context, modem.Candidate) (platformRegistration, bool) {
return platformRegistration{}, false
}
+80
View File
@@ -0,0 +1,80 @@
package device
import (
"regexp"
"strings"
)
type platformRegistration struct {
Status int
PLMN string
Name string
PSAttached bool
}
var qmiQuotedFieldPattern = regexp.MustCompile(`(?i)^\s*([^:]+):\s*'([^']*)'\s*$`)
func parseQMIRegistration(output string) (platformRegistration, bool) {
result := platformRegistration{}
registrationState := ""
roaming := false
mcc := ""
mnc := ""
pcsDigit := false
for _, rawLine := range strings.Split(output, "\n") {
match := qmiQuotedFieldPattern.FindStringSubmatch(strings.TrimSpace(rawLine))
if len(match) != 3 {
continue
}
key := strings.ToLower(strings.TrimSpace(match[1]))
value := strings.TrimSpace(match[2])
switch key {
case "registration state":
registrationState = strings.ToLower(value)
case "roaming status":
roaming = strings.EqualFold(value, "on")
case "ps":
result.PSAttached = strings.EqualFold(value, "attached")
case "mcc":
if mcc == "" {
mcc = value
}
case "mnc":
if mnc == "" {
mnc = value
}
case "description":
if result.Name == "" {
result.Name = value
}
case "mnc with pcs digit":
pcsDigit = strings.EqualFold(value, "yes")
}
}
switch registrationState {
case "registered":
result.Status = 1
if roaming {
result.Status = 5
}
case "not-registered-searching", "searching":
result.Status = 2
case "registration-denied", "denied":
result.Status = 3
case "not-registered":
result.Status = 0
default:
return platformRegistration{}, false
}
if decimalDigits(mcc, 3, 3) && decimalDigits(mnc, 1, 3) {
width := 2
if pcsDigit {
width = 3
}
for len(mnc) < width {
mnc = "0" + mnc
}
result.PLMN = mcc + mnc
}
return result, true
}
+31
View File
@@ -0,0 +1,31 @@
package device
import "testing"
func TestParseQMIRegistrationRegisteredRoaming(t *testing.T) {
output := `
Registration state: 'registered'
CS: 'attached'
PS: 'attached'
Roaming status: 'on'
Current PLMN:
MCC: '460'
MNC: '1'
Description: 'UNICOM'
Full operator code info:
MCC: '460'
MNC: '1'
MNC with PCS digit: 'no'
`
result, found := parseQMIRegistration(output)
if !found || result.Status != 5 || !result.PSAttached || result.PLMN != "46001" || result.Name != "UNICOM" {
t.Fatalf("registration = %#v, found=%v", result, found)
}
}
func TestParseQMIRegistrationSearching(t *testing.T) {
result, found := parseQMIRegistration("Registration state: 'not-registered-searching'\nPS: 'detached'")
if !found || result.Status != 2 || result.PSAttached {
t.Fatalf("registration = %#v, found=%v", result, found)
}
}
+24
View File
@@ -0,0 +1,24 @@
package device
import (
"testing"
"vocat/internal/modem"
)
func TestParseRegistrationStatus(t *testing.T) {
tests := []struct {
line string
want int
}{
{line: "+CEREG: 0,5", want: 5},
{line: "+CGREG: 2,1,\"FFFE\",\"06698D06\",7", want: 1},
{line: "+CREG: 2", want: 2},
}
for _, test := range tests {
got, ok := parseRegistrationStatus(modem.Response{Lines: []string{test.line}})
if !ok || got != test.want {
t.Fatalf("parseRegistrationStatus(%q) = %d, %v", test.line, got, ok)
}
}
}
+24 -1
View File
@@ -14,6 +14,7 @@ type ScannedOperator struct {
Name string `json:"name"` Name string `json:"name"`
Short string `json:"shortName,omitempty"` Short string `json:"shortName,omitempty"`
Numeric string `json:"numeric"` Numeric string `json:"numeric"`
Country string `json:"countryCode,omitempty"`
Act string `json:"act,omitempty"` Act string `json:"act,omitempty"`
} }
@@ -75,11 +76,19 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
if len(fields) < 4 { if len(fields) < 4 {
continue continue
} }
name, country, _ := CarrierForPLMN(fields[3])
if name == "" {
name = strings.TrimSpace(fields[1])
}
if name == "" {
name = strings.TrimSpace(fields[3])
}
operator := ScannedOperator{ operator := ScannedOperator{
Status: operatorScanStatus(fields[0]), Status: operatorScanStatus(fields[0]),
Name: fields[1], Name: name,
Short: fields[2], Short: fields[2],
Numeric: fields[3], Numeric: fields[3],
Country: country,
} }
if len(fields) >= 5 { if len(fields) >= 5 {
operator.Act = accessTechnology(fields[4]) operator.Act = accessTechnology(fields[4])
@@ -90,6 +99,20 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
return operators return operators
} }
// carrierNameForPLMN resolves the numeric serving PLMN through the bundled
// global carrier database. Some EC20 firmware returns an empty, localized, or
// stale long name even though the MCC/MNC is correct. The numeric identity is
// the authoritative value used for network selection.
func carrierNameForPLMN(plmn, fallback string) string {
if name, _, ok := CarrierForPLMN(plmn); ok {
return name
}
if fallback = strings.TrimSpace(fallback); fallback != "" {
return fallback
}
return strings.TrimSpace(plmn)
}
// extractScanTuples returns the contents of each top-level parenthesised group, // extractScanTuples returns the contents of each top-level parenthesised group,
// ignoring parentheses inside quoted strings. // ignoring parentheses inside quoted strings.
func extractScanTuples(payload string) []string { func extractScanTuples(payload string) []string {
+123
View File
@@ -0,0 +1,123 @@
package device
import (
"testing"
"vocat/internal/modem"
)
func TestParseOperatorScanNormalizesMainlandCarrierNamesByPLMN(t *testing.T) {
response := modem.Response{Lines: []string{
`+COPS: (1,"CMCC","CMCC","46000",7),(1,"wrong modem name","CU","46001",7),(1,"","CT","46011",7),(1,"CBN","CBN","46015",7)`,
}}
operators := parseOperatorScan(response)
if len(operators) != 4 {
t.Fatalf("operators = %#v", operators)
}
want := []string{"China Mobile", "China Unicom", "China Telecom", "China Broadnet"}
for index := range want {
if operators[index].Name != want[index] {
t.Fatalf("operator %d name = %q, want %q", index, operators[index].Name, want[index])
}
}
}
func TestCarrierNameForPLMNUsesGlobalDatabase(t *testing.T) {
if got := carrierNameForPLMN("23415", "stale modem name"); got != "Vodafone" {
t.Fatalf("carrier name = %q", got)
}
if got := carrierNameForPLMN("26202", ""); got != "Vodafone" {
t.Fatalf("German carrier name = %q", got)
}
if got := carrierNameForPLMN("310260", ""); got != "T-Mobile - US" {
t.Fatalf("US carrier name = %q", got)
}
if got := carrierNameForPLMN("99999", "Test Network"); got != "Test Network" {
t.Fatalf("unknown carrier fallback = %q", got)
}
}
func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
tests := map[string]string{
"23415": "GB",
"23487": "GB",
"26202": "DE",
"310260": "US",
"22201": "IT",
"72405": "BR",
"46015": "CN",
}
for plmn, wantCountry := range tests {
name, country, ok := CarrierForPLMN(plmn)
if !ok || name == "" || country != wantCountry {
t.Errorf("CarrierForPLMN(%q) = (%q, %q, %v), want a name and country %q", plmn, name, country, ok, wantCountry)
}
}
}
func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
tests := map[string]string{
"234": "GB",
"262": "DE",
"310": "US",
"460": "CN",
}
for mcc, want := range tests {
if got, ok := CountryForMCC(mcc); !ok || got != want {
t.Errorf("CountryForMCC(%q) = (%q, %v), want %q", mcc, got, ok, want)
}
}
for _, invalid := range []string{"", "23", "999", "abcd"} {
if got, ok := CountryForMCC(invalid); ok || got != "" {
t.Errorf("CountryForMCC(%q) = (%q, %v), want unknown", invalid, got, ok)
}
}
}
func 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
wantPLMN string
wantCountry string
}{
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
}
for _, item := range tests {
plmn, name, country, ok := CarrierForIMSI(item.imsi)
if !ok || plmn != item.wantPLMN || name == "" || country != item.wantCountry {
t.Errorf("CarrierForIMSI(%q) = (%q, %q, %q, %v), want PLMN %q and country %q", item.imsi, plmn, name, country, ok, item.wantPLMN, item.wantCountry)
}
}
}
func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
plmn, name, country, ok := CarrierForSIM(CarrierIdentity{
IMSI: "454006395879502", ICCID: "89852350126077295027",
SPN: "Saily", GID1: "536E617065", GID2: "536E617065000012", MNCLength: 2,
})
if !ok || plmn != "45400" || name != "Webbing" || country != "HK" {
t.Fatalf("CarrierForSIM exact rule = (%q, %q, %q, %v)", plmn, name, country, ok)
}
plmn, name, country, ok = CarrierForSIM(CarrierIdentity{
IMSI: "454006395879502", SPN: "Saily", MNCLength: 2,
})
if !ok || plmn != "45400" || name != "1O1O / csl / Club Sim" || country != "HK" {
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
}
}
+310 -19
View File
@@ -3,12 +3,14 @@ package device
import ( import (
"context" "context"
"encoding/csv" "encoding/csv"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode" "unicode"
"unicode/utf16"
"vocat/internal/modem" "vocat/internal/modem"
) )
@@ -17,6 +19,9 @@ func (manager *Manager) readSnapshot(
ctx context.Context, ctx context.Context,
id string, id string,
candidate modem.Candidate, candidate modem.Candidate,
backend string,
previousICCID string,
previousSnapshot *Snapshot,
client modem.Client, client modem.Client,
) (Snapshot, error) { ) (Snapshot, error) {
snapshot := Snapshot{ snapshot := Snapshot{
@@ -34,6 +39,19 @@ func (manager *Manager) readSnapshot(
if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") { if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") {
snapshot.Model = candidate.Product snapshot.Model = candidate.Product
} }
// Native MHI/QMI devices expose their immutable modem identity through DMS.
// Read it before any SIM-dependent AT probes: a missing/bad card can make
// those commands slow or fail, but must never prevent IMEI from appearing.
if strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.IMEI = qmiIMEI
} else {
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
}
}
optional := func(command string) (modem.Response, bool) { optional := func(command string) (modem.Response, bool) {
response, commandErr := manager.command(ctx, client, command) response, commandErr := manager.command(ctx, client, command)
@@ -47,11 +65,80 @@ func (manager *Manager) readSnapshot(
if response, ok := optional("AT+CPIN?"); ok { if response, ok := optional("AT+CPIN?"); ok {
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response) snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
} }
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
}
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
// timeout, and every refresh holds the device lock while it does so,
// starving the AT terminal. Only fall back to QMI when the AT CPIN
// probe already proved a card is present.
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.ICCID = qmiICCID
ccidErr = nil
} else {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID via QMI UIM: "+qmiErr.Error())
}
}
if ccidErr != nil {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
} else {
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) {
// A different physical SIM must never inherit the previous card's
// permission to use cellular RF. Disable RF before reading serving-cell
// or operator state; policy reconciliation will then start VoWiFi.
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
}
snapshot.SIMChanged = true
}
if response, ok := optional("AT+CIMI"); ok {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
}
// EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct
// from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore
// legitimately carry a Vodafone NL IMSI while still presenting Lebara as
// its customer-facing operator. Failure is intentionally silent because
// EF_SPN is optional and some physical SIMs deny CRSM access to it.
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
snapshot.SPN = parseSPN(response)
}
if previousSnapshot != nil && previousSnapshot.IdentityFilesRead &&
strings.EqualFold(strings.TrimSpace(previousSnapshot.ICCID), strings.TrimSpace(snapshot.ICCID)) {
snapshot.MNCLength = previousSnapshot.MNCLength
snapshot.GID1 = previousSnapshot.GID1
snapshot.GID2 = previousSnapshot.GID2
snapshot.IdentityFilesRead = true
} else {
// Android's carrier resolver does not identify MVNOs from MCC/MNC alone.
// Read these files once per inserted ICCID and cache even an empty result;
// repeatedly probing unsupported EFs would add avoidable modem traffic.
if efAD := manager.readTransparentSIMFile(ctx, client, 28589); len(efAD) >= 4 {
if length := int(efAD[3] & 0x0f); length == 2 || length == 3 {
snapshot.MNCLength = length
}
}
snapshot.GID1 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28478))
snapshot.GID2 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28479))
snapshot.IdentityFilesRead = true
}
if response, ok := optional("AT+CSQ"); ok { if response, ok := optional("AT+CSQ"); ok {
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response) snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
} }
servingPLMN := ""
if response, ok := optional(`AT+QENG="servingcell"`); ok { if response, ok := optional(`AT+QENG="servingcell"`); ok {
metrics := parseQENG(response) metrics := parseQENG(response)
servingPLMN = metrics.PLMN
snapshot.AccessTech = metrics.AccessTech snapshot.AccessTech = metrics.AccessTech
snapshot.Band = metrics.Band snapshot.Band = metrics.Band
snapshot.Channel = metrics.Channel snapshot.Channel = metrics.Channel
@@ -64,32 +151,74 @@ func (manager *Manager) readSnapshot(
} }
if response, ok := optional("AT+COPS?"); ok { if response, ok := optional("AT+COPS?"); ok {
operator := parseCOPS(response) operator := parseCOPS(response)
snapshot.OperatorName = operator.Name if operator.Code != "" {
snapshot.OperatorCode = operator.Code snapshot.OperatorCode = operator.Code
} else {
snapshot.OperatorCode = servingPLMN
}
snapshot.OperatorName = carrierNameForPLMN(snapshot.OperatorCode, operator.Name)
if snapshot.AccessTech == "" { if snapshot.AccessTech == "" {
snapshot.AccessTech = operator.AccessTech snapshot.AccessTech = operator.AccessTech
} }
} }
if response, ok := optional("AT+CGSN"); ok { for _, command := range []string{"AT+CEREG?", "AT+CGREG?", "AT+CREG?"} {
snapshot.IMEI = parseIdentifier( response, registrationErr := manager.command(ctx, client, command)
response, if registrationErr != nil {
[]string{"+CGSN:", "+GSN:"}, continue
14, }
17, if status, found := parseRegistrationStatus(response); found {
) snapshot.RegistrationStatus = status
snapshot.RegistrationSource = strings.TrimSuffix(strings.TrimPrefix(command, "AT+"), "?")
break
}
} }
if strings.EqualFold(backend, "qmi") {
ccid, ccidErr := manager.command(ctx, client, "AT+CCID") registration, found := readPlatformRegistration(ctx, candidate)
if ccidErr != nil { if found {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID") snapshot.RegistrationStatus = registration.Status
snapshot.RegistrationSource = "QMI NAS"
snapshot.PSAttached = registration.PSAttached
if registration.PLMN != "" {
snapshot.OperatorCode = registration.PLMN
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
}
}
} }
if ccidErr != nil { if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error()) // Older firmware can omit registration queries while COPS still proves
} else { // that an operator is selected.
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22) snapshot.RegistrationStatus = 1
snapshot.RegistrationSource = "COPS"
} }
if response, ok := optional("AT+CIMI"); ok { if snapshot.IMEI == "" {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18) // AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
// returns the IMEI line but never a final OK, so it would block until the
// caller's deadline (30s during a periodic refresh) and starve every other
// device operation behind the lock. Give it an independent short timeout
// and let the WWAN transport's drain discard the trailing stale bytes.
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
cancelCGSN()
if cgsnErr == nil {
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
snapshot.IMEI = imei
}
}
}
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.IMEI = qmiIMEI
} else {
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
}
}
if snapshot.IMEI == "" && previousSnapshot != nil {
// IMEI is hardware identity and does not change with the inserted card.
// Preserve a prior successful read across a transient QMI/AT failure.
snapshot.IMEI = previousSnapshot.IMEI
} }
if response, ok := optional("AT+CFUN?"); ok { if response, ok := optional("AT+CFUN?"); ok {
if mode, found := parseCFUN(response); found { if mode, found := parseCFUN(response); found {
@@ -107,6 +236,157 @@ func (manager *Manager) readSnapshot(
return snapshot, nil return snapshot, nil
} }
func (manager *Manager) readTransparentSIMFile(ctx context.Context, client modem.Client, fileID int) []byte {
response, err := manager.command(ctx, client, fmt.Sprintf("AT+CRSM=192,%d,0,0,0", fileID))
if err != nil {
return nil
}
size := transparentSIMFileSize(crsmPayload(response))
if size <= 0 || size > 64 {
return nil
}
response, err = manager.command(ctx, client, fmt.Sprintf("AT+CRSM=176,%d,0,0,%d", fileID, size))
if err != nil {
return nil
}
return crsmPayload(response)
}
func transparentSIMFileSize(payload []byte) int {
// USIM FCP templates contain file size in tag 0x80. Skip the outer 0x62
// template and walk its immediate TLVs.
content := payload
if len(content) >= 2 && content[0] == 0x62 {
length, header, ok := berLength(content[1:])
if !ok || 1+header+length > len(content) {
return 0
}
content = content[1+header : 1+header+length]
}
for offset := 0; offset+2 <= len(content); {
tag := content[offset]
length, header, ok := berLength(content[offset+1:])
start := offset + 1 + header
end := start + length
if !ok || end > len(content) {
break
}
if tag == 0x80 && (length == 1 || length == 2) {
size := 0
for _, value := range content[start:end] {
size = size<<8 | int(value)
}
return size
}
offset = end
}
// Legacy GSM GET RESPONSE data stores file size in bytes 2 and 3.
if len(payload) >= 4 && payload[0] != 0x62 {
return int(payload[2])<<8 | int(payload[3])
}
return 0
}
func berLength(value []byte) (length, header int, ok bool) {
if len(value) == 0 {
return 0, 0, false
}
if value[0] < 0x80 {
return int(value[0]), 1, true
}
count := int(value[0] & 0x7f)
if count == 0 || count > 2 || len(value) < count+1 {
return 0, 0, false
}
for _, item := range value[1 : count+1] {
length = length<<8 | int(item)
}
return length, count + 1, true
}
func encodeSIMGroupID(value []byte) string {
if len(value) == 0 {
return ""
}
allPadding := true
for _, item := range value {
if item != 0xff {
allPadding = false
break
}
}
if allPadding {
return ""
}
return strings.ToUpper(hex.EncodeToString(value))
}
func parseSPN(response modem.Response) string {
value := valueAfterPrefix(response, "+CRSM:")
fields := csvValues(value)
if len(fields) < 3 {
return ""
}
sw1, sw1Err := strconv.Atoi(strings.TrimSpace(fields[0]))
sw2, sw2Err := strconv.Atoi(strings.TrimSpace(fields[1]))
if sw1Err != nil || sw2Err != nil || (sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f) || sw2 < 0 || sw2 > 255 {
return ""
}
raw, err := hex.DecodeString(strings.Trim(strings.TrimSpace(fields[2]), `"`))
if err != nil || len(raw) < 2 {
return ""
}
alpha := raw[1:] // byte 0 is the display-condition bit field.
for len(alpha) > 0 && (alpha[len(alpha)-1] == 0xff || alpha[len(alpha)-1] == 0x00) {
alpha = alpha[:len(alpha)-1]
}
if len(alpha) == 0 {
return ""
}
if alpha[0] == 0x80 {
ucs2 := alpha[1:]
if len(ucs2)%2 != 0 {
ucs2 = ucs2[:len(ucs2)-1]
}
units := make([]uint16, 0, len(ucs2)/2)
for index := 0; index+1 < len(ucs2); index += 2 {
unit := uint16(ucs2[index])<<8 | uint16(ucs2[index+1])
if unit != 0xffff && unit != 0 {
units = append(units, unit)
}
}
return strings.TrimSpace(string(utf16.Decode(units)))
}
// EF_SPN uses the unpacked GSM default alphabet. Its printable Latin subset
// is byte-compatible with UTF-8/ASCII and covers operator brands in practice.
printable := make([]byte, 0, len(alpha))
for _, value := range alpha {
if value >= 0x20 && value <= 0x7e {
printable = append(printable, value)
}
}
return strings.TrimSpace(string(printable))
}
func parseRegistrationStatus(response modem.Response) (int, bool) {
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
values := csvValues(valueAfterPrefix(response, prefix))
if len(values) == 0 {
continue
}
index := 0
// Query responses are <n>,<stat>; unsolicited responses are <stat>.
if len(values) >= 2 {
index = 1
}
status, err := strconv.Atoi(strings.TrimSpace(values[index]))
if err == nil && status >= 0 && status <= 10 {
return status, true
}
}
return 0, false
}
func parseATI(lines []string) (manufacturer, model, firmware string) { func parseATI(lines []string) (manufacturer, model, firmware string) {
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@@ -159,6 +439,7 @@ func parseCSQ(response modem.Response) (raw, percent, dbm *int) {
} }
type qengMetrics struct { type qengMetrics struct {
PLMN string
AccessTech string AccessTech string
Band string Band string
Channel string Channel string
@@ -179,6 +460,9 @@ func parseQENG(response modem.Response) qengMetrics {
} }
result := qengMetrics{AccessTech: strings.ToUpper(values[2])} result := qengMetrics{AccessTech: strings.ToUpper(values[2])}
if strings.EqualFold(values[2], "LTE") && len(values) >= 17 { if strings.EqualFold(values[2], "LTE") && len(values) >= 17 {
if decimalDigits(values[4], 3, 3) && decimalDigits(values[5], 2, 3) {
result.PLMN = values[4] + values[5]
}
result.Channel = values[8] result.Channel = values[8]
if values[9] != "" { if values[9] != "" {
result.Band = "B" + values[9] result.Band = "B" + values[9]
@@ -193,6 +477,13 @@ func parseQENG(response modem.Response) qengMetrics {
return qengMetrics{} return qengMetrics{}
} }
func decimalDigits(value string, minimum, maximum int) bool {
value = strings.TrimSpace(value)
return len(value) >= minimum && len(value) <= maximum && strings.IndexFunc(value, func(character rune) bool {
return character < '0' || character > '9'
}) < 0
}
type operatorInfo struct { type operatorInfo struct {
Name string Name string
Code string Code string
+142
View File
@@ -0,0 +1,142 @@
package device
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"vocat/internal/modem"
)
// lenientATClient answers every command with a bare CommandError and records
// the commands it saw. It lets snapshot tests exercise the full readSnapshot
// sequence without enumerating every step of the transcript.
type lenientATClient struct {
mu sync.Mutex
commands []string
cgsnDelay time.Duration
cgsnIMEI string
}
func (c *lenientATClient) Execute(ctx context.Context, command string) (modem.Response, error) {
c.mu.Lock()
c.commands = append(c.commands, command)
c.mu.Unlock()
if command == "ATI" {
return okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410"), nil
}
if command == "AT+CGSN" && c.cgsnDelay > 0 {
select {
case <-time.After(c.cgsnDelay):
case <-ctx.Done():
}
}
if command == "AT+CGSN" && c.cgsnIMEI != "" {
return okResponse("+CGSN: " + c.cgsnIMEI), nil
}
return modem.Response{}, &modem.CommandError{Command: command, Final: "ERROR"}
}
func (c *lenientATClient) WaitURC(context.Context, func(string) bool) (string, error) {
return "", errors.New("no URC")
}
func (c *lenientATClient) Close() error { return nil }
func (c *lenientATClient) saw(command string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, seen := range c.commands {
if seen == command {
return true
}
}
return false
}
// AT+CGSN on some MHI modems returns the IMEI line but never a final OK, so it
// would block until the caller's deadline and hold the device lock for the
// whole periodic refresh. The snapshot must bound CGSN with its own short
// timeout instead of inheriting the refresh deadline.
func TestManagerRefreshBoundsCGSNTimeout(t *testing.T) {
client := &lenientATClient{cgsnDelay: 5 * time.Second}
manager, id := newStartedTestManager(t, client)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
start := time.Now()
snapshot, err := manager.Refresh(ctx, id)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Refresh: %v", err)
}
// CGSN times out after CommandTimeout (1s in the test manager); the rest
// of the snapshot is immediate. An un-bounded CGSN would wait for the
// 4s outer deadline (or worse, a real 30s refresh deadline).
if elapsed > 3*time.Second {
t.Fatalf("Refresh took %s; CGSN was not bounded by CommandTimeout", elapsed)
}
if !client.saw("AT+CGSN") {
t.Fatalf("CGSN was never sent; commands = %v", client.commands)
}
if snapshot.IMEI != "" {
t.Fatalf("IMEI = %q, want empty after CGSN timeout", snapshot.IMEI)
}
}
// A missing SIM must not fall back to the QMI UIM ICCID read: without a READY
// card that call blocks until its long timeout and starves the AT terminal
// behind the device lock.
func TestManagerRefreshSkipsQMIICCIDWithoutReadySIM(t *testing.T) {
// CGSN succeeds so the snapshot does not fall back to the QMI DMS IMEI
// read either; the test focuses on the UIM ICCID fallback being skipped
// without a READY card.
client := &lenientATClient{cgsnIMEI: "866241014372802"}
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
ID: "mhi-wwan0",
Product: "PCIe/MHI WWAN modem",
QMIControl: "/dev/wwan0qmi0",
NetworkInterface: "wwan0",
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
}}},
Opener: &staticOpener{client: client},
})
if err != nil {
t.Fatal(err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
qmiCalls := 0
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
qmiCalls++
return nil, errors.New("QMI should not be opened without a SIM")
}
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
t.Fatal(err)
}
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
if err != nil {
t.Fatalf("Refresh: %v", err)
}
// Exactly one QMI open is expected: the immutable DMS IMEI read runs
// unconditionally for native QMI candidates (IMEI is hardware identity,
// independent of the card). The UIM ICCID fallback, which would block
// without a READY SIM, must be skipped.
if qmiCalls != 1 {
t.Fatalf("qmiRadioOpener called %d times, want 1 (DMS IMEI only, UIM ICCID must be skipped without a READY SIM)", qmiCalls)
}
for _, warning := range snapshot.Warnings {
if strings.Contains(warning, "QMI UIM") {
t.Fatalf("unexpected QMI ICCID warning: %q", warning)
}
}
}
+46 -32
View File
@@ -11,6 +11,7 @@ var (
ErrNotStarted = errors.New("device manager is not started") ErrNotStarted = errors.New("device manager is not started")
ErrNotFound = errors.New("device not found") ErrNotFound = errors.New("device not found")
ErrNoATPort = errors.New("device has no usable AT port") ErrNoATPort = errors.New("device has no usable AT port")
ErrUnsupportedCapability = errors.New("device does not support this capability")
ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode") ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode")
ErrSMSInvalidRecipient = errors.New("invalid SMS recipient") ErrSMSInvalidRecipient = errors.New("invalid SMS recipient")
ErrSMSEmpty = errors.New("SMS text is empty") ErrSMSEmpty = errors.New("SMS text is empty")
@@ -24,9 +25,13 @@ var (
) )
type NetworkRequest struct { type NetworkRequest struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
APN string `json:"apn"` APN string `json:"apn"`
IPVersion string `json:"ipVersion"` IPVersion string `json:"ipVersion"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Authentication string `json:"authentication,omitempty"`
Backend string `json:"backend,omitempty"`
} }
type NetworkResult struct { type NetworkResult struct {
@@ -73,35 +78,44 @@ const (
) )
type Snapshot struct { type Snapshot struct {
DeviceID string `json:"deviceId"` DeviceID string `json:"deviceId"`
Port string `json:"port"` Port string `json:"port"`
Responsive bool `json:"responsive"` Responsive bool `json:"responsive"`
Manufacturer string `json:"manufacturer"` Manufacturer string `json:"manufacturer"`
Model string `json:"model"` Model string `json:"model"`
Firmware string `json:"firmware"` Firmware string `json:"firmware"`
SIMStatus string `json:"simStatus"` SIMStatus string `json:"simStatus"`
SIMReady bool `json:"simReady"` SIMReady bool `json:"simReady"`
SignalRaw *int `json:"signalRaw,omitempty"` SIMChanged bool `json:"simChanged,omitempty"`
SignalPercent *int `json:"signalPercent,omitempty"` SignalRaw *int `json:"signalRaw,omitempty"`
RSSIDBm *int `json:"rssiDbm,omitempty"` SignalPercent *int `json:"signalPercent,omitempty"`
RSRP *int `json:"rsrp,omitempty"` RSSIDBm *int `json:"rssiDbm,omitempty"`
RSRQ *int `json:"rsrq,omitempty"` RSRP *int `json:"rsrp,omitempty"`
SINR *int `json:"sinr,omitempty"` RSRQ *int `json:"rsrq,omitempty"`
AccessTech string `json:"accessTech"` SINR *int `json:"sinr,omitempty"`
Band string `json:"band"` AccessTech string `json:"accessTech"`
Channel string `json:"channel"` Band string `json:"band"`
OperatorName string `json:"operatorName"` Channel string `json:"channel"`
OperatorCode string `json:"operatorCode"` OperatorName string `json:"operatorName"`
IMEI string `json:"imei"` OperatorCode string `json:"operatorCode"`
ICCID string `json:"iccid"` RegistrationStatus int `json:"registrationStatus"`
IMSI string `json:"imsi"` RegistrationSource string `json:"registrationSource"`
OperatingMode int `json:"operatingMode"` PSAttached bool `json:"psAttached"`
ModeKnown bool `json:"modeKnown"` IMEI string `json:"imei"`
FlightMode bool `json:"flightMode"` ICCID string `json:"iccid"`
RadioOff bool `json:"radioOff"` IMSI string `json:"imsi"`
Phone PhoneNumber `json:"phone"` SPN string `json:"spn,omitempty"`
Warnings []string `json:"warnings,omitempty"` MNCLength int `json:"mncLength,omitempty"`
UpdatedAt time.Time `json:"updatedAt"` GID1 string `json:"gid1,omitempty"`
GID2 string `json:"gid2,omitempty"`
IdentityFilesRead bool `json:"-"`
OperatingMode int `json:"operatingMode"`
ModeKnown bool `json:"modeKnown"`
FlightMode bool `json:"flightMode"`
RadioOff bool `json:"radioOff"`
Phone PhoneNumber `json:"phone"`
Warnings []string `json:"warnings,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type USSDResult struct { type USSDResult struct {
+194
View File
@@ -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
}
+98
View File
@@ -0,0 +1,98 @@
//go:build linux
package exportproxy
import (
"bufio"
"context"
"hash/fnv"
"net"
"os"
"strings"
"syscall"
"unicode"
)
func platformSupported() error { return nil }
func boundDialer(networkInterface string) net.Dialer {
return net.Dialer{Control: func(_, _ string, raw syscall.RawConn) error {
var bindError error
err := raw.Control(func(fd uintptr) {
if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(exportRouteMark(networkInterface))); err != nil {
bindError = err
return
}
bindError = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, networkInterface)
})
if err != nil {
return err
}
return bindError
}}
}
func exportRouteMark(networkInterface string) uint32 {
hash := fnv.New32a()
_, _ = hash.Write([]byte(networkInterface))
return 0x56000000 | (hash.Sum32() & 0x00ffffff)
}
func boundResolver(networkInterface string) *net.Resolver {
dialer := boundDialer(networkInterface)
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
var lastError error
for _, server := range exportRouteDNSServers(networkInterface) {
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(server, "53"))
if err == nil {
return connection, nil
}
lastError = err
}
return nil, lastError
}}
}
func exportRouteDNSServers(networkInterface string) []string {
if !validInterfaceName(networkInterface) {
return []string{"1.1.1.1", "8.8.8.8"}
}
root, err := os.OpenRoot("/run/vocat")
if err != nil {
return []string{"1.1.1.1", "8.8.8.8"}
}
defer root.Close()
file, err := root.Open("cellular-" + networkInterface + ".dns")
if err != nil {
return []string{"1.1.1.1", "8.8.8.8"}
}
defer file.Close()
servers := make([]string, 0, 2)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if value := strings.TrimSpace(scanner.Text()); net.ParseIP(value) != nil {
servers = append(servers, value)
}
}
if len(servers) == 0 {
return []string{"1.1.1.1", "8.8.8.8"}
}
return servers
}
// Linux IFNAMSIZ is 16 including the terminator. Restricting names here both
// matches kernel interface names and prevents a stored device value from ever
// becoming a filesystem path component.
func validInterfaceName(value string) bool {
if value == "" || len(value) > 15 || value == "." || value == ".." {
return false
}
for _, character := range value {
if character > unicode.MaxASCII || !(character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' ||
character == '-' || character == '_' || character == '.') {
return false
}
}
return true
}
+18
View File
@@ -0,0 +1,18 @@
//go:build linux
package exportproxy
import "testing"
func TestValidInterfaceName(t *testing.T) {
for _, value := range []string{"wwan0", "wwp0s20f0u5i4", "rmnet_data0", "usb.1"} {
if !validInterfaceName(value) {
t.Errorf("validInterfaceName(%q) = false", value)
}
}
for _, value := range []string{"", ".", "..", "../wwan0", `..\wwan0`, "wwan0/evil", "interface-name-too-long"} {
if validInterfaceName(value) {
t.Errorf("validInterfaceName(%q) = true", value)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
package exportproxy
import (
"errors"
"net"
)
func platformSupported() error { return errors.New("built-in export proxy is only available on Linux") }
func boundDialer(string) net.Dialer { return net.Dialer{} }
func boundResolver(string) *net.Resolver { return net.DefaultResolver }
+88
View File
@@ -0,0 +1,88 @@
package exportproxy
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const ipInfoURL = "https://ipinfo.io/json"
type PublicIPInfo struct {
IP string `json:"ip"`
CountryCode string `json:"country_code"`
Region string `json:"region"`
City string `json:"city"`
Organization string `json:"organization,omitempty"`
}
// LookupPublicIP sends the lookup through the same marked, interface-bound
// dialer and isolated DNS resolver as Export Proxy. It therefore reports the
// modem's roaming exit rather than the host or browser's default connection.
func LookupPublicIP(ctx context.Context, networkInterface string) (PublicIPInfo, error) {
networkInterface = strings.TrimSpace(networkInterface)
if networkInterface == "" {
return PublicIPInfo{}, errors.New("cellular network interface is required")
}
if err := platformSupported(); err != nil {
return PublicIPInfo{}, err
}
dialer := boundDialer(networkInterface)
resolver := boundResolver(networkInterface)
transport := &http.Transport{
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
return dialTarget(ctx, address, &dialer, resolver)
},
DisableKeepAlives: true,
ResponseHeaderTimeout: 12 * time.Second,
}
defer transport.CloseIdleConnections()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, ipInfoURL, nil)
if err != nil {
return PublicIPInfo{}, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "VoCat/1.0")
response, err := transport.RoundTrip(request)
if err != nil {
return PublicIPInfo{}, fmt.Errorf("query ipinfo.io through %s: %w", networkInterface, err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
return PublicIPInfo{}, fmt.Errorf("ipinfo.io returned HTTP %d", response.StatusCode)
}
return decodePublicIPInfo(io.LimitReader(response.Body, 64<<10))
}
func decodePublicIPInfo(reader io.Reader) (PublicIPInfo, error) {
var response struct {
IP string `json:"ip"`
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
Org string `json:"org"`
}
if err := json.NewDecoder(reader).Decode(&response); err != nil {
return PublicIPInfo{}, fmt.Errorf("decode ipinfo.io response: %w", err)
}
response.IP = strings.TrimSpace(response.IP)
response.Country = strings.ToUpper(strings.TrimSpace(response.Country))
if net.ParseIP(response.IP) == nil {
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid IP address")
}
if len(response.Country) != 2 {
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid country code")
}
return PublicIPInfo{
IP: response.IP, CountryCode: response.Country,
Region: strings.TrimSpace(response.Region), City: strings.TrimSpace(response.City),
Organization: strings.TrimSpace(response.Org),
}, nil
}
+22
View File
@@ -0,0 +1,22 @@
package exportproxy
import (
"strings"
"testing"
)
func TestDecodePublicIPInfo(t *testing.T) {
info, err := decodePublicIPInfo(strings.NewReader(`{"ip":"203.0.113.8","city":"London","region":"England","country":"gb","org":"AS64500 Test"}`))
if err != nil {
t.Fatal(err)
}
if info.IP != "203.0.113.8" || info.CountryCode != "GB" || info.Region != "England" || info.City != "London" {
t.Fatalf("info = %+v", info)
}
}
func TestDecodePublicIPInfoRejectsInvalidResponse(t *testing.T) {
if _, err := decodePublicIPInfo(strings.NewReader(`{"ip":"not-an-ip","country":"GB"}`)); err == nil {
t.Fatal("invalid IP was accepted")
}
}

Some files were not shown because too many files have changed in this diff Show More