mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6291d0254 | ||
|
|
2f40c64f3f | ||
|
|
392d44f919 | ||
|
|
8accaaaabc | ||
|
|
9eebcc1773 | ||
|
|
ff4c1ab036 | ||
|
|
1dc6bcccd9 | ||
|
|
be40e324db | ||
|
|
63553eaf2b | ||
|
|
72e0af6eb9 | ||
|
|
c66fe06def | ||
|
|
489a6dc10c | ||
|
|
9c39e15bcf | ||
|
|
3b8f32f591 | ||
|
|
0318670f49 | ||
|
|
d06afdb076 | ||
|
|
b56acc0e3a | ||
|
|
60cc636969 | ||
|
|
73a72680ad | ||
|
|
60501d4831 | ||
|
|
2c843d82a4 | ||
|
|
ad66456d2f | ||
|
|
161aa667c9 | ||
|
|
8137fc875b | ||
|
|
1df338f9b3 | ||
|
|
20f91fac72 | ||
|
|
30880f6612 | ||
|
|
fdb2ac45ce | ||
|
|
95299a0a39 | ||
|
|
1a4032d013 | ||
|
|
6ec950bfd2 | ||
|
|
04dd026e95 | ||
|
|
a2a72dfd30 | ||
|
|
67079c99b9 | ||
|
|
f949001480 | ||
|
|
fa8afb9571 | ||
|
|
bfda29193a | ||
|
|
ed64fd428a | ||
|
|
f84a1f99b1 | ||
|
|
e2177a6e9a | ||
|
|
115598757a | ||
|
|
297d2c1839 | ||
|
|
7ba30132f9 | ||
|
|
82494f519b | ||
|
|
5eee89a92a | ||
|
|
ae3a2a6eea | ||
|
|
505ee1eac0 | ||
|
|
b19ae2240a | ||
|
|
adf7de6d29 | ||
|
|
ffa0fd23b8 | ||
|
|
f014628048 |
@@ -49,13 +49,13 @@ jobs:
|
||||
BUILD_TIME=${{ github.event.repository.updated_at }}
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
|
||||
- name: Verify ${{ matrix.platform }} runtime, QMI, 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'
|
||||
'command -v qmicli && command -v qmi-network && command -v pcscd && test -d /usr/lib/pcsc/drivers'
|
||||
|
||||
build-and-push:
|
||||
needs: smoke
|
||||
|
||||
@@ -29,23 +29,15 @@ jobs:
|
||||
env:
|
||||
MAX_CHANGED_LINES: "5000"
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted base repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check conflicts and pull request size
|
||||
- name: Check conflicts and pull request size via GitHub API
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Checking PR #${PR_NUMBER}"
|
||||
echo "Base branch: ${BASE_REF}"
|
||||
|
||||
############################################################
|
||||
# Helper: comment on and close rejected PR
|
||||
@@ -94,25 +86,40 @@ jobs:
|
||||
}
|
||||
|
||||
############################################################
|
||||
# Fetch target branch and PR HEAD
|
||||
# Fetch PR metadata from GitHub REST API
|
||||
############################################################
|
||||
|
||||
echo "Fetching base branch and PR head..."
|
||||
echo "Fetching pull request metadata from GitHub API..."
|
||||
|
||||
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}"
|
||||
PR_JSON=""
|
||||
for attempt in {1..10}; do
|
||||
PR_JSON="$(
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--request GET \
|
||||
--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}"
|
||||
)"
|
||||
|
||||
BASE_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/base-pr-check
|
||||
)"
|
||||
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
|
||||
if [[ "${MERGEABLE}" != "null" ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
PR_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/pr-${PR_NUMBER}
|
||||
)"
|
||||
echo "Mergeable state is calculating, waiting 2s (attempt ${attempt}/10)..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Base commit: ${BASE_COMMIT}"
|
||||
echo "PR commit: ${PR_COMMIT}"
|
||||
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
|
||||
ADDITIONS="$(echo "${PR_JSON}" | jq -r '.additions // 0')"
|
||||
DELETIONS="$(echo "${PR_JSON}" | jq -r '.deletions // 0')"
|
||||
CHANGED_FILES="$(echo "${PR_JSON}" | jq -r '.changed_files // 0')"
|
||||
|
||||
CHANGED_LINES=$((ADDITIONS + DELETIONS))
|
||||
|
||||
############################################################
|
||||
# STEP 1: Reject PRs with merge conflicts
|
||||
@@ -121,19 +128,7 @@ jobs:
|
||||
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
|
||||
if [[ "${MERGEABLE}" == "false" ]]; then
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
@@ -144,94 +139,9 @@ jobs:
|
||||
|
||||
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))
|
||||
echo "No merge conflicts detected (mergeable: ${MERGEABLE})."
|
||||
|
||||
############################################################
|
||||
# Action summary
|
||||
@@ -256,7 +166,7 @@ jobs:
|
||||
echo "Limit: ${MAX_CHANGED_LINES}"
|
||||
|
||||
############################################################
|
||||
# STEP 4: Reject oversized PRs
|
||||
# STEP 2: Reject oversized PRs
|
||||
############################################################
|
||||
|
||||
if (( CHANGED_LINES > MAX_CHANGED_LINES )); then
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Sync Apple Carrier Bundles
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Sunday at midnight UTC
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Run carrier bundles sync
|
||||
run: |
|
||||
go run ./cmd/sync_carrier_bundles
|
||||
|
||||
- name: Run tests on generated profiles
|
||||
run: |
|
||||
go test -v ./internal/vowifi
|
||||
|
||||
- name: Create Pull Request or commit updates
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
commit-message: "chore(vowifi): sync Apple carrier bundles offline database"
|
||||
title: "chore(vowifi): sync Apple carrier bundles offline database"
|
||||
body: |
|
||||
Automated sync from `dwilliamsuk/ios-carrier-bundles` latest release.
|
||||
Updated `internal/vowifi/carrier_profiles.json`.
|
||||
branch: "sync-apple-carrier-bundles"
|
||||
delete-branch: true
|
||||
+1
-1
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
||||
|
||||
# ---- Stage 3: minimal runtime ----
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \
|
||||
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite qmi-utils tzdata && \
|
||||
addgroup -S -g 1000 vocat && \
|
||||
adduser -S -D -H -u 1000 -G vocat vocat
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
Copyright (c) 2013, Dustin L. Howett. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The views and conclusions contained in the software and documentation are
|
||||
those of the authors and should not be interpreted as representing official
|
||||
policies, either expressed or implied, of the FreeBSD Project.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Parts of this package were made available under the license covering the Go
|
||||
language and all attended core libraries. That license follows.
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -96,6 +96,14 @@ to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
If matching kernel modules are unavailable, use a firmware that includes them;
|
||||
never force-install kmods built for a different kernel.
|
||||
|
||||
If your kernel cannot provide XFRM/IPsec and you only need non-VoWiFi features
|
||||
such as cellular SMS or data, install with `--skip-vowifi-check`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh --skip-vowifi-check
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
|
||||
@@ -188,6 +196,11 @@ those fixed nodes and does not provide complete multi-device or hot-plug discove
|
||||
|
||||
The GHCR image is published for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / QNAP Container Station Deployment Note**:
|
||||
> On NAS operating systems like QNAP QTS / QuTS hero (Container Station), custom non-root administrator accounts and volume isolation mechanisms may cause Docker named volumes (e.g. `-v vocat-data:/opt/vocat/data`) to resolve to different isolated paths between the one-off `bootstrap-admin` initialization and the daemon service container, leading to "Incorrect password" errors during Web login.
|
||||
> For NAS environments, it is strongly recommended to replace named volumes with a host absolute path bind mount (e.g. `-v /share/Container/vocat/data:/opt/vocat/data` on QNAP) for both initialization and runtime to guarantee consistent SQLite database persistence.
|
||||
|
||||
### USB SIM readers
|
||||
|
||||
USB SIM readers use the Linux PC/SC service. The one-click installer installs
|
||||
@@ -197,6 +210,19 @@ managers. On Debian/Ubuntu, the equivalent manual setup is
|
||||
VoCat keeps the reader visible in the add-device dialog and reports the missing
|
||||
service or driver instead of silently hiding it.
|
||||
|
||||
### QMI command-line utilities
|
||||
|
||||
VoCat uses `qmicli` to verify that a QMI control channel is ready and
|
||||
`qmi-network` to manage packet-data sessions. The one-click installer installs
|
||||
and verifies the corresponding utilities automatically. For manual deployment,
|
||||
Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses
|
||||
`pacman -S libqmi`, Alpine uses `apk add qmi-utils`, and OpenWrt uses
|
||||
`opkg install qmi-utils`.
|
||||
|
||||
`vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB
|
||||
driver binding or asserting DTR. If the utility is unavailable, the command
|
||||
stops with an installation hint and leaves the current device state untouched.
|
||||
|
||||
## Configuration
|
||||
|
||||
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
|
||||
@@ -212,6 +238,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. |
|
||||
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
||||
|
||||
User-supplied Apple carrier bundles can be converted into reviewable,
|
||||
allow-listed carrier profiles with `vocat carrier import-ipcc`; see
|
||||
[docs/CARRIER_IPCC_IMPORT.md](docs/CARRIER_IPCC_IMPORT.md).
|
||||
|
||||
Administrator credentials are stored only in SQLite. Initialize an empty
|
||||
database once with `vocat bootstrap-admin`; environment variables and JSON
|
||||
configuration cannot set or overwrite the administrator username or password.
|
||||
@@ -340,7 +370,7 @@ cd web && npm run build
|
||||
## Thanks
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
|
||||
- [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
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const defaultTarURL = "https://github.com/dwilliamsuk/ios-carrier-bundles/archive/refs/heads/latest.tar.gz"
|
||||
|
||||
func main() {
|
||||
tarURL := flag.String("url", defaultTarURL, "URL to ios-carrier-bundles tar.gz archive")
|
||||
localTar := flag.String("file", "", "path to local .tar.gz archive")
|
||||
outputFile := flag.String("output", filepath.Join("internal", "vowifi", "carrier_profiles.json"), "output carrier_profiles.json path")
|
||||
flag.Parse()
|
||||
|
||||
var reader io.Reader
|
||||
if *localTar != "" {
|
||||
f, err := os.Open(*localTar)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", *localTar, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
reader = f
|
||||
} else {
|
||||
fmt.Printf("Downloading %s ...\n", *tarURL)
|
||||
client := &http.Client{Timeout: 3 * time.Minute}
|
||||
resp, err := client.Get(*tarURL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Download error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "HTTP %s\n", resp.Status)
|
||||
os.Exit(1)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Read error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Downloaded %d bytes. Parsing archive...\n", len(data))
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
gz, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Gzip error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
bundlePlists := make(map[string]map[string][]byte)
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Tar error: %v\n", err)
|
||||
break
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(hdr.Name, "\\", "/")
|
||||
if strings.Contains(strings.ToLower(name), "/signatures/") {
|
||||
continue
|
||||
}
|
||||
base := path.Base(name)
|
||||
if !strings.EqualFold(base, "carrier.plist") && (!strings.HasPrefix(strings.ToLower(base), "overrides") || !strings.EqualFold(path.Ext(base), ".plist")) {
|
||||
continue
|
||||
}
|
||||
// e.g. ios-carrier-bundles-latest/Carrier Bundles/EE_uk.bundle/carrier.plist
|
||||
bundleDir := path.Dir(name)
|
||||
bundleName := path.Base(bundleDir)
|
||||
if !strings.HasSuffix(strings.ToLower(bundleName), ".bundle") {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if bundlePlists[bundleName] == nil {
|
||||
bundlePlists[bundleName] = make(map[string][]byte)
|
||||
}
|
||||
bundlePlists[bundleName][base] = content
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d distinct carrier bundles. Extracting VoWiFi profiles...\n", len(bundlePlists))
|
||||
|
||||
var sortedBundleNames []string
|
||||
for k := range bundlePlists {
|
||||
sortedBundleNames = append(sortedBundleNames, k)
|
||||
}
|
||||
sort.Strings(sortedBundleNames)
|
||||
|
||||
var extractedRules []any
|
||||
seenIDs := make(map[string]bool)
|
||||
successCount := 0
|
||||
skipCount := 0
|
||||
|
||||
for _, bundleName := range sortedBundleNames {
|
||||
plists := bundlePlists[bundleName]
|
||||
rule, _, err := vowifi.ImportCarrierBundlePlists(bundleName, plists)
|
||||
if err != nil {
|
||||
skipCount++
|
||||
continue
|
||||
}
|
||||
if seenIDs[rule.ID] {
|
||||
continue
|
||||
}
|
||||
seenIDs[rule.ID] = true
|
||||
extractedRules = append(extractedRules, rule)
|
||||
successCount++
|
||||
}
|
||||
|
||||
fmt.Printf("Extracted %d valid carrier profile rules (skipped %d without valid VoWiFi selectors).\n", successCount, skipCount)
|
||||
|
||||
doc := map[string]any{
|
||||
"version": vowifi.CarrierProfileSchemaVersion,
|
||||
"metadata": map[string]any{
|
||||
"source": "dwilliamsuk/ios-carrier-bundles",
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"count": len(extractedRules),
|
||||
},
|
||||
"profiles": extractedRules,
|
||||
}
|
||||
|
||||
encoded, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "JSON encode error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(*outputFile, append(encoded, '\n'), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Write error to %s: %v\n", *outputFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully wrote %d rules (%d bytes) to %s\n", len(extractedRules), len(encoded), *outputFile)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func runCarrier(args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: vocat carrier import-ipcc [flags] FILE.ipcc")
|
||||
}
|
||||
switch args[0] {
|
||||
case "import-ipcc":
|
||||
return runCarrierImportIPCC(args[1:], stdout)
|
||||
default:
|
||||
return fmt.Errorf("unknown carrier subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runCarrierImportIPCC(args []string, stdout io.Writer) error {
|
||||
flags := flag.NewFlagSet("carrier import-ipcc", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
var bundle string
|
||||
var profileID string
|
||||
var profileDir string
|
||||
var install bool
|
||||
var documentOnly bool
|
||||
flags.StringVar(&bundle, "bundle", "", "bundle name when an IPCC contains more than one carrier bundle")
|
||||
flags.StringVar(&profileID, "id", "", "override the generated carrier profile ID")
|
||||
flags.StringVar(&profileDir, "profile-dir", "", "installation directory (default: next to the VoCat database)")
|
||||
flags.BoolVar(&install, "install", false, "atomically install the reviewed generated profile")
|
||||
flags.BoolVar(&documentOnly, "document-only", false, "print only the generated carrier profile document")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 1 {
|
||||
return errors.New("usage: vocat carrier import-ipcc [--bundle NAME] [--id ID] [--document-only] [--install] [--profile-dir DIR] FILE.ipcc")
|
||||
}
|
||||
if documentOnly && install {
|
||||
return errors.New("--document-only and --install cannot be used together")
|
||||
}
|
||||
if strings.TrimSpace(profileDir) != "" && !install {
|
||||
return errors.New("--profile-dir requires --install")
|
||||
}
|
||||
result, err := vowifi.ImportCarrierIPCC(flags.Arg(0), vowifi.IPCCImportOptions{
|
||||
Bundle: bundle,
|
||||
ProfileID: profileID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if documentOnly {
|
||||
_, err := stdout.Write(result.Document)
|
||||
return err
|
||||
}
|
||||
|
||||
installedPath := ""
|
||||
if install {
|
||||
profileDir = strings.TrimSpace(profileDir)
|
||||
if profileDir == "" {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration for carrier profile directory: %w", err)
|
||||
}
|
||||
profileDir = filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||
}
|
||||
installedPath, err = vowifi.InstallCarrierIPCCResult(result, profileDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absolute, absoluteErr := filepath.Abs(installedPath); absoluteErr == nil {
|
||||
installedPath = absolute
|
||||
}
|
||||
}
|
||||
output := struct {
|
||||
vowifi.IPCCImportResult
|
||||
InstalledPath string `json:"installed_path,omitempty"`
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}{
|
||||
IPCCImportResult: result,
|
||||
InstalledPath: installedPath,
|
||||
RestartRequired: installedPath != "",
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(output)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
func TestRunCarrierImportIPCCPreviewsAndInstallsExplicitly(t *testing.T) {
|
||||
archivePath := filepath.Join(t.TempDir(), "test.ipcc")
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(file)
|
||||
entry, err := archive.Create("Payload/Test.bundle/carrier.plist")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := plist.NewEncoder(&encoded).Encode(map[string]any{
|
||||
"CarrierName": "Test Carrier",
|
||||
"SupportedSIMs": []any{"99901"},
|
||||
"SupportedPLMNs": []any{"99901"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var preview bytes.Buffer
|
||||
if err := runCarrier([]string{"import-ipcc", "--document-only", archivePath}, &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(preview.Bytes(), &document); err != nil || document.Version != 1 {
|
||||
t.Fatalf("preview = %q, version=%d, error=%v", preview.String(), document.Version, err)
|
||||
}
|
||||
|
||||
installDir := t.TempDir()
|
||||
var output bytes.Buffer
|
||||
if err := runCarrier([]string{
|
||||
"import-ipcc", "--id", "cli-test", "--install", "--profile-dir", installDir, archivePath,
|
||||
}, &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var installed struct {
|
||||
InstalledPath string `json:"installed_path"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
}
|
||||
if err := json.Unmarshal(output.Bytes(), &installed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installed.RestartRequired || filepath.Base(installed.InstalledPath) != "cli-test.json" {
|
||||
t.Fatalf("install output = %s", output.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "cli-test.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,16 @@ Usage:
|
||||
vocat serve Run the server in the foreground (use from a TTY when
|
||||
vocat without arguments would enter the menu).
|
||||
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 restore the factory-ID
|
||||
DJI/Baiwang 2ca3:4006 AT/QMI interface bindings and wake
|
||||
QMI without changing NV.
|
||||
vocat carrier import-ipcc [flags] FILE.ipcc
|
||||
Convert an Apple carrier bundle into a reviewable VoCat
|
||||
profile. Preview is the default; --install writes it to
|
||||
carrier-profiles.d and takes effect after restart.
|
||||
Flags: --bundle NAME --id ID --document-only --install
|
||||
--profile-dir DIR.
|
||||
vocat update Check GitHub for a newer release and self-update.
|
||||
Flags:
|
||||
--check Only report whether an update is available.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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"`
|
||||
SerialInterfaces []string `json:"serial_interfaces,omitempty"`
|
||||
SerialDevices []string `json:"serial_devices,omitempty"`
|
||||
ATDevice string `json:"at_device,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, "bind DJI 2ca3:4006 interfaces 0-3 to option and interface 4 to qmi_wwan, then 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_usb_interfaces_repaired", "DJI serial interfaces 0-3 were bound to option and interface 4 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
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//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"
|
||||
djiFirstSerialIndex = 0
|
||||
djiLastSerialIndex = 3
|
||||
djiATIndex = 2
|
||||
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) {
|
||||
qmicli, err := exec.LookPath("qmicli")
|
||||
if err != nil {
|
||||
return djiQMIRepairResult{}, errors.New("qmicli is required to verify DJI QMI readiness; install libqmi-utils on Debian/Ubuntu/Fedora, libqmi on Arch Linux, or qmi-utils on Alpine")
|
||||
}
|
||||
return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) {
|
||||
return repairDJIQMIAt(attemptContext, "/sys", "/dev", qmicli)
|
||||
})
|
||||
}
|
||||
|
||||
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, qmicli 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))
|
||||
|
||||
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||
if err := ensureUSBDriverLoaded(ctx, driversRoot, "qmi_wwan", "qmi_wwan"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := ensureUSBDriverLoaded(ctx, driversRoot, "option", "option"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// qmi_wwan's USB dynamic ID is device-wide. Leaving it installed makes it
|
||||
// probe every vendor-specific interface after a USBIP reconnect; on this DJI
|
||||
// composition that can turn interfaces 1-3 into bogus cdc-wdm devices and
|
||||
// remove the AT port. Remove it before detaching anything, then add it only
|
||||
// briefly below while interface 4 is the sole unbound interface.
|
||||
qmiDriverRoot := filepath.Join(driversRoot, "qmi_wwan")
|
||||
if err := removeDynamicUSBID(qmiDriverRoot, djiVendorID+" "+djiProductID); err != nil {
|
||||
return result, fmt.Errorf("remove broad DJI qmi_wwan dynamic ID: %w", err)
|
||||
}
|
||||
|
||||
serialInterfaces, serialDevices, atDevice, err := bindDJISerialInterfaces(ctx, sysRoot, devRoot, usbRoot, driversRoot, result.USBName)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SerialInterfaces = serialInterfaces
|
||||
result.SerialDevices = serialDevices
|
||||
result.ATDevice = atDevice
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
interfaceDetached := false
|
||||
restoreOriginal := func() {
|
||||
if !interfaceDetached {
|
||||
return
|
||||
}
|
||||
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
||||
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
|
||||
}
|
||||
switch result.OriginalDriver {
|
||||
case "qmi_wwan":
|
||||
_ = bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface)
|
||||
case "option":
|
||||
_ = 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
|
||||
}
|
||||
|
||||
if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// The requested driver topology is now established. A later DMS timeout is
|
||||
// a QMI/USBIP readiness problem, so do not roll interface 4 back to option.
|
||||
interfaceDetached = false
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
|
||||
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
||||
probeContextErr := probeContext.Err()
|
||||
cancelProbe()
|
||||
result.QMIProbe = strings.TrimSpace(string(output))
|
||||
if probeErr != nil {
|
||||
if probeContextErr != nil {
|
||||
probeErr = errors.Join(probeErr, probeContextErr)
|
||||
}
|
||||
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func bindDJIQMIInterface(driverRoot, interfacePath, interfaceName string) (returnErr error) {
|
||||
bindPath := filepath.Join(driverRoot, "bind")
|
||||
dynamicIDAdded := false
|
||||
defer func() {
|
||||
if dynamicIDAdded {
|
||||
removeErr := removeDynamicUSBID(driverRoot, djiVendorID+" "+djiProductID)
|
||||
if returnErr == nil && removeErr != nil {
|
||||
returnErr = fmt.Errorf("remove temporary DJI qmi_wwan dynamic ID: %w", removeErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := writeSysfs(bindPath, interfaceName); err != nil {
|
||||
newIDErr := writeSysfs(filepath.Join(driverRoot, "new_id"), djiVendorID+" "+djiProductID)
|
||||
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
|
||||
return fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
|
||||
}
|
||||
dynamicIDAdded = true
|
||||
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
|
||||
if retryErr := writeSysfs(bindPath, interfaceName); retryErr != nil {
|
||||
return fmt.Errorf("bind qmi_wwan to %s: %w", interfaceName, retryErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
|
||||
return fmt.Errorf("interface %s driver is %q after qmi_wwan bind", interfaceName, driver)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureUSBDriverLoaded(ctx context.Context, driversRoot, driverName, moduleName string) error {
|
||||
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("inspect %s driver: %w", driverName, err)
|
||||
}
|
||||
modprobe, err := exec.LookPath("modprobe")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is not loaded and modprobe is unavailable", driverName)
|
||||
}
|
||||
if output, loadErr := exec.CommandContext(ctx, modprobe, moduleName).CombinedOutput(); loadErr != nil {
|
||||
return fmt.Errorf("load %s: %w: %s", moduleName, loadErr, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err != nil {
|
||||
return fmt.Errorf("%s driver is unavailable after loading module %s: %w", driverName, moduleName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindDJISerialInterfaces(
|
||||
ctx context.Context,
|
||||
sysRoot, devRoot, usbRoot, driversRoot, usbName string,
|
||||
) ([]string, []string, string, error) {
|
||||
interfaceNames := make([]string, 0, djiLastSerialIndex-djiFirstSerialIndex+1)
|
||||
interfacePaths := make([]string, 0, cap(interfaceNames))
|
||||
needsDynamicID := false
|
||||
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||
name := fmt.Sprintf("%s:1.%d", usbName, index)
|
||||
path := filepath.Join(usbRoot, name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("DJI serial interface %s unavailable: %w", name, err)
|
||||
}
|
||||
driver := usbInterfaceDriver(path)
|
||||
if driver != "" && driver != "option" && driver != "qmi_wwan" {
|
||||
return nil, nil, "", fmt.Errorf("refusing to replace unexpected driver %q on %s", driver, name)
|
||||
}
|
||||
interfaceNames = append(interfaceNames, name)
|
||||
interfacePaths = append(interfacePaths, path)
|
||||
needsDynamicID = needsDynamicID || driver != "option"
|
||||
}
|
||||
|
||||
if needsDynamicID {
|
||||
// Detach every false QMI claim before option's new_id triggers probing.
|
||||
for index, path := range interfacePaths {
|
||||
if usbInterfaceDriver(path) != "qmi_wwan" {
|
||||
continue
|
||||
}
|
||||
if err := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "unbind"), interfaceNames[index]); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("unbind qmi_wwan from serial interface %s: %w", interfaceNames[index], err)
|
||||
}
|
||||
}
|
||||
|
||||
optionSerialRoot := filepath.Join(sysRoot, "bus", "usb-serial", "drivers", "option1")
|
||||
if _, err := os.Stat(optionSerialRoot); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("option USB-serial driver is unavailable: %w", err)
|
||||
}
|
||||
if err := writeSysfs(filepath.Join(optionSerialRoot, "new_id"), djiVendorID+" "+djiProductID); err != nil && !errors.Is(err, syscall.EEXIST) {
|
||||
return nil, nil, "", fmt.Errorf("register DJI option dynamic ID: %w", err)
|
||||
}
|
||||
|
||||
for index, path := range interfacePaths {
|
||||
if usbInterfaceDriver(path) == "option" {
|
||||
continue
|
||||
}
|
||||
if err := writeSysfs(filepath.Join(driversRoot, "option", "bind"), interfaceNames[index]); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("bind option to %s: %w", interfaceNames[index], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index, path := range interfacePaths {
|
||||
if driver := usbInterfaceDriver(path); driver != "option" {
|
||||
return nil, nil, "", fmt.Errorf("serial interface %s driver is %q after option bind", interfaceNames[index], driver)
|
||||
}
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
serialDevices := make([]string, len(interfacePaths))
|
||||
for {
|
||||
complete := true
|
||||
for index, path := range interfacePaths {
|
||||
name := firstEntryName(path, "ttyUSB")
|
||||
if name == "" {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
serialDevices[index] = filepath.Join(devRoot, name)
|
||||
}
|
||||
if complete {
|
||||
break
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, nil, "", fmt.Errorf("option bound but not all ttyUSB nodes appeared for %s", usbName)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
return interfaceNames, serialDevices, serialDevices[djiATIndex-djiFirstSerialIndex], nil
|
||||
}
|
||||
|
||||
func removeDynamicUSBID(driverRoot, id string) error {
|
||||
path := filepath.Join(driverRoot, "remove_id")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := writeSysfs(path, id); err != nil && !errors.Is(err, syscall.ENODEV) && !errors.Is(err, syscall.ENOENT) {
|
||||
return err
|
||||
}
|
||||
return 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 ""
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 TestRepairDJIQMIRequiresQMICLIBeforeUSBAccess(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
_, err := repairDJIQMI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("repairDJIQMI() unexpectedly succeeded without qmicli")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "qmicli is required") || !strings.Contains(err.Error(), "libqmi-utils") {
|
||||
t.Fatalf("repairDJIQMI() error = %q, want an actionable qmicli prerequisite error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "DTR repair attempt") || strings.Contains(err.Error(), "USB topology") {
|
||||
t.Fatalf("repairDJIQMI() touched the repair path before checking qmicli: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDJISerialInterfaceLayout(t *testing.T) {
|
||||
if djiFirstSerialIndex != 0 || djiLastSerialIndex != 3 || djiATIndex != 2 || djiQMIIndex != 4 {
|
||||
t.Fatalf(
|
||||
"DJI interface layout = serial %d-%d, AT %d, QMI %d; want serial 0-3, AT 2, QMI 4",
|
||||
djiFirstSerialIndex,
|
||||
djiLastSerialIndex,
|
||||
djiATIndex,
|
||||
djiQMIIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindDJISerialInterfacesAlreadyCorrect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||
optionRoot := filepath.Join(driversRoot, "option")
|
||||
if err := os.MkdirAll(optionRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||
interfacePath := filepath.Join(usbRoot, fmt.Sprintf("1-1:1.%d", index))
|
||||
if err := os.MkdirAll(filepath.Join(interfacePath, fmt.Sprintf("ttyUSB%d", index)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(optionRoot, filepath.Join(interfacePath, "driver")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
interfaces, devices, atDevice, err := bindDJISerialInterfaces(
|
||||
context.Background(),
|
||||
sysRoot,
|
||||
devRoot,
|
||||
usbRoot,
|
||||
driversRoot,
|
||||
"1-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bindDJISerialInterfaces() error = %v", err)
|
||||
}
|
||||
if len(interfaces) != 4 || interfaces[2] != "1-1:1.2" {
|
||||
t.Fatalf("interfaces = %#v, want four interfaces with AT at 1-1:1.2", interfaces)
|
||||
}
|
||||
if len(devices) != 4 || devices[2] != filepath.Join(devRoot, "ttyUSB2") {
|
||||
t.Fatalf("devices = %#v, want four devices with AT at ttyUSB2", devices)
|
||||
}
|
||||
if atDevice != filepath.Join(devRoot, "ttyUSB2") {
|
||||
t.Fatalf("AT device = %q, want %q", atDevice, filepath.Join(devRoot, "ttyUSB2"))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,29 @@ func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
|
||||
t.Fatal("installer replaces the current binary before validating database compatibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerProvidesRequiredQMIUtilities(t *testing.T) {
|
||||
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(scriptBytes)
|
||||
for _, required := range []string{
|
||||
"install_qmi_support()",
|
||||
"command -v qmicli",
|
||||
"command -v qmi-network",
|
||||
"apt-get install -y libqmi-utils",
|
||||
"dnf install -y libqmi-utils",
|
||||
"pacman -Sy --noconfirm libqmi",
|
||||
"apk add --no-cache qmi-utils",
|
||||
"Could not install or find qmicli/qmi-network",
|
||||
} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Errorf("installer is missing required QMI handling %q", required)
|
||||
}
|
||||
}
|
||||
mainStart := strings.LastIndex(script, "# --- Main ")
|
||||
if mainStart < 0 || !strings.Contains(script[mainStart:], "install_qmi_support") {
|
||||
t.Error("installer does not install QMI utilities from its main path")
|
||||
}
|
||||
}
|
||||
|
||||
+88
-14
@@ -77,6 +77,16 @@ func main() {
|
||||
logger.Error("update failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "doctor":
|
||||
if err := runDoctor(rest); err != nil {
|
||||
logger.Error("doctor failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "carrier":
|
||||
if err := runCarrier(rest, os.Stdout); err != nil {
|
||||
logger.Error("carrier command failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "menu":
|
||||
if err := runMenu(logger); err != nil {
|
||||
logger.Error("menu failed", "error", err)
|
||||
@@ -120,6 +130,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
carrierProfileDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||
if err := vowifi.LoadCarrierProfileDirectory(carrierProfileDir); err != nil {
|
||||
return fmt.Errorf("load installed carrier profiles: %w", err)
|
||||
}
|
||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +206,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}
|
||||
|
||||
cardReaders := pcsc.New()
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device manager: %w", err)
|
||||
}
|
||||
@@ -223,12 +237,20 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
||||
}
|
||||
|
||||
var onIncomingCall func(context.Context, ims.ReceivedCall) error
|
||||
|
||||
vowifiManager, err := configureVoWiFiRuntime(
|
||||
startupContext,
|
||||
logger,
|
||||
database,
|
||||
deviceManager,
|
||||
cardReaders,
|
||||
func(ctx context.Context, call ims.ReceivedCall) error {
|
||||
if onIncomingCall != nil {
|
||||
return onIncomingCall(ctx, call)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
||||
@@ -262,10 +284,24 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
onIncomingCall = func(ctx context.Context, call ims.ReceivedCall) error {
|
||||
deviceConfig, _ := database.Device(ctx, call.DeviceID)
|
||||
handler.NotifyIncomingCall(ctx, server.IncomingCallNotification{
|
||||
DeviceID: call.DeviceID,
|
||||
DeviceName: strings.TrimSpace(deviceConfig.Name),
|
||||
DeviceLabel: firstNonEmpty(deviceConfig.Name, deviceConfig.ID, "--"),
|
||||
Caller: call.Caller,
|
||||
Called: call.Called,
|
||||
Time: call.Timestamp,
|
||||
Environment: "vowifi",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
go handler.StartLogRetentionLoop(pollContext, time.Minute)
|
||||
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
||||
handler.StartTelegramBot(pollContext)
|
||||
handler.StartSMSNotificationDispatchers(pollContext)
|
||||
go handler.StartCellularCallMonitor(pollContext)
|
||||
handler.StartAutomaticTasks(pollContext)
|
||||
|
||||
serverConfig := func(handler http.Handler) *http.Server {
|
||||
@@ -561,6 +597,7 @@ func configureVoWiFiRuntime(
|
||||
database *store.Store,
|
||||
deviceManager *device.Manager,
|
||||
cardReaders *pcsc.Service,
|
||||
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||
) (*vowifiruntime.Manager, error) {
|
||||
mapper := integration.ATMapper{
|
||||
Store: database,
|
||||
@@ -616,7 +653,7 @@ func configureVoWiFiRuntime(
|
||||
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
||||
adapter = nativeQMIAdapter
|
||||
}
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger, onIncomingCall)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -680,7 +717,7 @@ func protectVoWiFiStartupRadioWithRetry(
|
||||
physicalID string,
|
||||
attempts int,
|
||||
delay time.Duration,
|
||||
) error {
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
@@ -715,26 +752,27 @@ func newVoWiFiOrchestrator(
|
||||
deviceConfig store.Device,
|
||||
database *store.Store,
|
||||
adapter vowifiDeviceAdapter,
|
||||
logger *slog.Logger,
|
||||
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||
) (*vowifi.Orchestrator, error) {
|
||||
apn := deviceConfig.APN
|
||||
if apn == "" {
|
||||
apn = "ims"
|
||||
}
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||
}
|
||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||
// The userspace SWu data plane carries protected P-CSCF signalling over
|
||||
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
|
||||
Transport: "tcp",
|
||||
TransportByPLMN: map[string]string{
|
||||
"23410": "udp",
|
||||
"234010": "udp",
|
||||
},
|
||||
// Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes
|
||||
// this service-centre number for manual SMS setup.
|
||||
SMSCenter: "+447785016005",
|
||||
Logger: logger,
|
||||
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||
// alternate transport only if no SIP response was observed.
|
||||
Transport: "tcp",
|
||||
AutoTransportFallback: true,
|
||||
OnIncomingCall: onIncomingCall,
|
||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"transport": "ims",
|
||||
@@ -746,6 +784,7 @@ func newVoWiFiOrchestrator(
|
||||
"service_center_timestamp": message.ServiceCenterTimestamp,
|
||||
"raw_rpdu": message.RawRPDU,
|
||||
"raw_tpdu": message.RawTPDU,
|
||||
"decode_error": message.DecodeError,
|
||||
})
|
||||
partsTotal := 1
|
||||
if message.Concat != nil && message.Concat.Total > 0 {
|
||||
@@ -810,6 +849,31 @@ func newVoWiFiOrchestrator(
|
||||
// acknowledged, otherwise the SMSC will keep retransmitting it.
|
||||
return nil
|
||||
},
|
||||
OnUSSD: func(ctx context.Context, message ims.ReceivedUSSD) error {
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"transport": "ims-ussd",
|
||||
"dcs": message.DCS,
|
||||
"call_id": message.CallID,
|
||||
"received_at": message.Timestamp,
|
||||
"raw_body": message.RawBody,
|
||||
})
|
||||
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: message.MessageID,
|
||||
DeviceID: message.DeviceID,
|
||||
ModemIMEI: deviceConfig.ModemIMEI,
|
||||
IMSI: message.IMSI,
|
||||
Peer: message.From,
|
||||
Direction: "inbound",
|
||||
Body: message.Text,
|
||||
Timestamp: message.Timestamp,
|
||||
Status: "received",
|
||||
Source: "ims-ussd",
|
||||
PartsTotal: 1,
|
||||
Read: false,
|
||||
Extra: extra,
|
||||
})
|
||||
return saveErr
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IMS provider: %w", deviceConfig.ID, err)
|
||||
@@ -1289,3 +1353,13 @@ func liftCardRegionBlock(
|
||||
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
||||
)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/vowifi"
|
||||
"vocat/internal/vowifi/integration"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,10 @@ func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Contex
|
||||
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 {
|
||||
|
||||
@@ -53,6 +53,9 @@ services:
|
||||
|
||||
volumes:
|
||||
# SQLite database + persistent state.
|
||||
# Note for NAS (e.g. QNAP Container Station / Synology): replace named volume
|
||||
# with a host absolute path (e.g. /share/Container/vocat/data:/opt/vocat/data)
|
||||
# to avoid volume isolation issues between bootstrap-admin and runtime.
|
||||
- vocat-data:/opt/vocat/data
|
||||
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
|
||||
# devices added after the container starts.
|
||||
|
||||
@@ -185,6 +185,11 @@ Quectel USB المدعومة (معرّف الشركة المصنعة USB `2c7c`)
|
||||
|
||||
تُنشر صورة GHCR لـ `linux/amd64` و`linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **ملاحظة حول النشر على NAS / QNAP Container Station**:
|
||||
> في أنظمة NAS مثل QNAP QTS / QuTS hero (Container Station)، قد تؤدي حسابات المشرفين المخصصة وآليات عزل وحدات التخزين إلى توجيه وحدات تخزين Docker المسماة (مثل `-v vocat-data:/opt/vocat/data`) إلى مسارات معزولة مختلفة بين أمر التهيئة `bootstrap-admin` وحاوية الخدمة الرئيسية، مما يتسبب في ظهور خطأ في كلمة المرور عند تسجيل الدخول عبر الويب.
|
||||
> بالنسبة لبيئات NAS، يوصى بشدة باستبدال وحدات التخزين المسماة بربط مسار مطلق على المضيف (مثل `-v /share/Container/vocat/data:/opt/vocat/data` على QNAP) لكل من التهيئة والتشغيل لضمان استمرارية متسقة لقاعدة بيانات SQLite.
|
||||
|
||||
## الإعدادات
|
||||
|
||||
يقرأ Vocat ملف إعدادات JSON اختياريًا من `VOCAT_CONFIG`، ثم يطبق متغيرات البيئة `VOCAT_*`. متغيرات البيئة لها الأولوية.
|
||||
|
||||
@@ -187,6 +187,11 @@ dispositivos o de conexión en caliente.
|
||||
|
||||
La imagen GHCR se publica para `linux/amd64` y `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **Nota sobre el despliegue en NAS / QNAP Container Station**:
|
||||
> En sistemas NAS como QNAP QTS / QuTS hero (Container Station), las cuentas de administrador personalizadas y el aislamiento de volúmenes pueden hacer que los volúmenes con nombre de Docker (ej. `-v vocat-data:/opt/vocat/data`) se resuelvan en rutas aisladas distintas entre la inicialización `bootstrap-admin` y el contenedor del servicio principal, provocando errores de contraseña incorrecta al iniciar sesión en la interfaz web.
|
||||
> En entornos NAS, se recomienda encarecidamente sustituir los volúmenes con nombre por un montaje bind con ruta absoluta del host (ej. `-v /share/Container/vocat/data:/opt/vocat/data` en QNAP) tanto para la inicialización como para la ejecución, garantizando la persistencia coherente de la base de datos SQLite.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -187,6 +187,11 @@ pas une découverte multi-périphériques ou à chaud complète.
|
||||
|
||||
L'image GHCR est publiée pour `linux/amd64` et `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **Note de déploiement NAS / QNAP Container Station** :
|
||||
> Sur les systèmes NAS tels que QNAP QTS / QuTS hero (Container Station), les comptes administrateurs personnalisés et les mécanismes d'isolation de volumes peuvent faire en sorte que les volumes nommés Docker (ex. `-v vocat-data:/opt/vocat/data`) soient résolus vers des chemins isolés différents entre l'initialisation unique `bootstrap-admin` et le conteneur de service principal, entraînant des erreurs de mot de passe incorrect sur l'interface Web.
|
||||
> Pour les environnements NAS, il est fortement recommandé de remplacer les volumes nommés par un montage bind avec chemin absolu de l'hôte (ex. `-v /share/Container/vocat/data:/opt/vocat/data` sur QNAP) pour l'initialisation et l'exécution afin de garantir une persistance cohérente de la base de données SQLite.
|
||||
|
||||
## 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é.
|
||||
|
||||
@@ -169,6 +169,11 @@ docker run -d \
|
||||
|
||||
GHCR イメージは `linux/amd64` と `linux/arm64` 向けに公開されています。
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / QNAP Container Station デプロイ時の注意点**:
|
||||
> QNAP QTS / QuTS hero (Container Station) などの NAS 環境では、非 root カスタム管理者権限とボリューム分離メカニズムにより、Docker の名前付きボリューム(例: `-v vocat-data:/opt/vocat/data`)を使用すると、初回の `bootstrap-admin` 初期化時とデーモン起動時で異なる隔離パスに書き込まれ、Web ログイン時にパスワードエラーとなる場合があります。
|
||||
> NAS 環境では、初期化と常駐コンテナの両方で名前付きボリュームの代わりにホストの絶対パスバインドマウント(例: QNAP の `-v /share/Container/vocat/data:/opt/vocat/data`)を使用することを推奨します。
|
||||
|
||||
## 設定
|
||||
|
||||
Vocat は `VOCAT_CONFIG` からオプションの JSON 設定ファイルを読み込み、次に `VOCAT_*` 環境変数を適用します。環境変数が優先されます。
|
||||
|
||||
@@ -186,6 +186,11 @@ TUN, настройки сети и устройств, добавленных
|
||||
|
||||
Образ GHCR публикуется для `linux/amd64` и `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **Примечание по развертыванию на NAS / QNAP Container Station**:
|
||||
> В системах NAS, таких как QNAP QTS / QuTS hero (Container Station), из-за нестандартных прав администратора и механизмов изоляции томов именованные тома Docker (например, `-v vocat-data:/opt/vocat/data`) могут разрешаться в разные изолированные пути между выполнением команды `bootstrap-admin` и основным контейнером службы, что приводит к ошибкам неверного пароля при входе через веб-интерфейс.
|
||||
> Для сред NAS настоятельно рекомендуется использовать монтирование с абсолютным путем хоста (например, `-v /share/Container/vocat/data:/opt/vocat/data` на QNAP) как для инициализации, так и для запуска службы, чтобы гарантировать согласованность базы данных SQLite.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Vocat читает необязательный JSON-файл конфигурации из `VOCAT_CONFIG`, затем применяет переменные окружения `VOCAT_*`. Переменные окружения имеют приоритет.
|
||||
|
||||
@@ -92,6 +92,13 @@ 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。
|
||||
|
||||
如果你的内核确实无法提供 XFRM/IPsec,且仅需要非 VoWiFi 功能(蜂窝短信、数据等),可在安装时加上 `--skip-vowifi-check`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh --skip-vowifi-check
|
||||
```
|
||||
|
||||
安装程序会:
|
||||
|
||||
- 检测 `amd64`、`386`、`arm64` 或 `armv7` 架构;
|
||||
@@ -168,6 +175,11 @@ docker run -d \
|
||||
|
||||
GHCR 镜像发布为 `linux/amd64` 与 `linux/arm64`。
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / 威联通 (QNAP Container Station) 部署说明**:
|
||||
> 在威联通等 NAS 系统的 Container Station 下部署时,由于系统的非 Root 自定义管理员权限与卷隔离机制,使用 Docker 命名卷(如 `-v vocat-data:/opt/vocat/data`)在执行一次性初始化 `bootstrap-admin` 和启动常驻服务时,两者的卷极易被解析至不同的隔离路径,导致 Web 端登录时提示密码错误。
|
||||
> 建议在 NAS 环境下部署时,将 `-v vocat-data:/opt/vocat/data` 替换为宿主机的绝对路径挂载(例如威联通上的 `-v /share/Container/vocat/data:/opt/vocat/data`),以确保初始化与运行期读写同一个 SQLite 数据库文件。
|
||||
|
||||
### USB SIM 读卡器
|
||||
|
||||
USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支持的软件包管理器上
|
||||
@@ -175,6 +187,16 @@ USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支
|
||||
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
|
||||
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。
|
||||
|
||||
### QMI 命令行工具
|
||||
|
||||
VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理
|
||||
分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时,
|
||||
Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用
|
||||
`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`,OpenWrt 使用 `opkg install qmi-utils`。
|
||||
|
||||
`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
|
||||
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
||||
|
||||
## 配置
|
||||
|
||||
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
||||
@@ -195,6 +217,25 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
||||
|
||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||
|
||||
## Apple IPCC 运营商规则导入
|
||||
|
||||
VoCat 可以离线解析用户提供的 `.ipcc`,将 Apple 的 XML/二进制 plist
|
||||
转换为可审查的运营商 Profile。默认只预览,不会修改配置:
|
||||
|
||||
```bash
|
||||
vocat carrier import-ipcc Carrier_iPhone.ipcc
|
||||
```
|
||||
|
||||
确认警告和匹配范围后,使用 `--install` 安装;重启 VoCat 后生效:
|
||||
|
||||
```bash
|
||||
vocat carrier import-ipcc --install Carrier_iPhone.ipcc
|
||||
```
|
||||
|
||||
导入器不会复制关闭证书验证、绕过运营商授权、APN 凭据、紧急呼叫或
|
||||
设备型号专属媒体参数。完整字段和冲突处理说明见
|
||||
[CARRIER_IPCC_IMPORT.md](CARRIER_IPCC_IMPORT.md)。
|
||||
|
||||
## Telegram 机器人
|
||||
|
||||
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
||||
|
||||
@@ -169,6 +169,11 @@ docker run -d \
|
||||
|
||||
GHCR 映像發佈為 `linux/amd64` 與 `linux/arm64`。
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / 威聯通 (QNAP Container Station) 部署說明**:
|
||||
> 在威聯通等 NAS 系統的 Container Station 下部署時,由於系統的非 Root 自訂管理員權限與磁碟區隔離機制,使用 Docker 具名磁碟區(如 `-v vocat-data:/opt/vocat/data`)在執行一次性初始化 `bootstrap-admin` 與啟動常駐服務時,兩者的磁碟區極易被解析至不同的隔離路徑,導致 Web 端登入時提示密碼錯誤。
|
||||
> 建議在 NAS 環境下部署時,將 `-v vocat-data:/opt/vocat/data` 替換為宿主機的絕對路徑掛載(例如威聯通上的 `-v /share/Container/vocat/data:/opt/vocat/data`),以確保初始化與執行期讀寫同一個 SQLite 資料庫檔案。
|
||||
|
||||
## 配置
|
||||
|
||||
Vocat 先從 `VOCAT_CONFIG` 讀取可選的 JSON 配置檔,再套用 `VOCAT_*` 環境變數。環境變數優先級更高。
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
# 企业微信消息推送实现计划
|
||||
|
||||
> **面向 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`
|
||||
|
||||
预期:PASS,GET/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`
|
||||
|
||||
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
|
||||
@@ -1,55 +0,0 @@
|
||||
# 企业微信消息推送设计
|
||||
|
||||
## 目标
|
||||
|
||||
新增独立的 `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 保留。前端构建用于验证新增表单与类型契约。
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.43.0
|
||||
howett.net/plist v1.0.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -49,10 +50,13 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
|
||||
@@ -324,6 +324,10 @@ func hashPassword(password string, cost int) ([]byte, error) {
|
||||
material := []byte(password)
|
||||
longPassword := len(material) > bcryptPasswordLimit
|
||||
if longPassword {
|
||||
// SHA-256 here is strictly a fixed-length condenser for bcrypt's 72-byte limit,
|
||||
// not a standalone password hash. bcrypt provides the actual adaptive work factor.
|
||||
// codeql[go/weak-cryptographic-hash]
|
||||
// codeql[go/sensitive-data-hasher]
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
}
|
||||
@@ -340,6 +344,8 @@ func hashPassword(password string, cost int) ([]byte, error) {
|
||||
func comparePassword(passwordHash []byte, password string) error {
|
||||
material := []byte(password)
|
||||
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
|
||||
// codeql[go/weak-cryptographic-hash]
|
||||
// codeql[go/sensitive-data-hasher]
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
passwordHash = passwordHash[len(longPasswordHashPrefix):]
|
||||
|
||||
@@ -3,6 +3,7 @@ package device
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) {
|
||||
return country, len(country) == 2
|
||||
}
|
||||
|
||||
// MCCsByCountry returns the complete MCC grouping from the embedded carrier
|
||||
// database, keyed by ISO alpha-2 country/territory code. The returned map and
|
||||
// slices are new values and may be safely modified by callers.
|
||||
func MCCsByCountry() map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
for mcc, rawCountry := range globalCarrierDatabase.Countries {
|
||||
country := strings.ToUpper(strings.TrimSpace(rawCountry))
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
if len(country) != 2 || len(mcc) != 3 {
|
||||
continue
|
||||
}
|
||||
result[country] = append(result[country], mcc)
|
||||
}
|
||||
for country := range result {
|
||||
sort.Strings(result[country])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var globalCarrierDatabase = func() carrierDatabase {
|
||||
var database carrierDatabase
|
||||
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICSTCCAe+gAwIBAgIQbmhWeneg7nyF7hg5Y9+qejAKBggqhkjOPQQDAjBEMRgw
|
||||
FgYDVQQKEw9HU00gQXNzb2NpYXRpb24xKDAmBgNVBAMTH0dTTSBBc3NvY2lhdGlv
|
||||
biAtIFJTUDIgUm9vdCBDSTEwIBcNMTcwMjIyMDAwMDAwWhgPMjA1MjAyMjEyMzU5
|
||||
NTlaMEQxGDAWBgNVBAoTD0dTTSBBc3NvY2lhdGlvbjEoMCYGA1UEAxMfR1NNIEFz
|
||||
c29jaWF0aW9uIC0gUlNQMiBSb290IENJMTBZMBMGByqGSM49AgEGCCqGSM49AwEH
|
||||
A0IABJ1qutL0HCMX52GJ6/jeibsAqZfULWj/X10p/Min6seZN+hf5llovbCNuB2n
|
||||
unLz+O8UD0SUCBUVo8e6n9X1TuajgcAwgb0wDgYDVR0PAQH/BAQDAgEGMA8GA1Ud
|
||||
EwEB/wQFMAMBAf8wEwYDVR0RBAwwCogIKwYBBAGC6WAwFwYDVR0gAQH/BA0wCzAJ
|
||||
BgdngRIBAgEAME0GA1UdHwRGMEQwQqBAoD6GPGh0dHA6Ly9nc21hLWNybC5zeW1h
|
||||
dXRoLmNvbS9vZmZsaW5lY2EvZ3NtYS1yc3AyLXJvb3QtY2kxLmNybDAdBgNVHQ4E
|
||||
FgQUgTcPUSXQsdQI1MOyMubSXnlb6/swCgYIKoZIzj0EAwIDSAAwRQIgIJdYsOMF
|
||||
WziPK7l8nh5mu0qiRiVf25oa9ullG/OIASwCIQDqCmDrYf+GziHXBOiwJwnBaeBO
|
||||
aFsiLzIEOaUuZwdNUw==
|
||||
-----END CERTIFICATE-----
|
||||
+32
-1
@@ -3,6 +3,7 @@ package device
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -14,9 +15,24 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
// GSM Association RSP2 Root CI1; SHA-256 fingerprint:
|
||||
// 5E:3E:91:FD:45:43:27:C3:AF:5D:32:A7:A7:3B:BC:59:FE:43:AA:7D:85:FD:32:D5:DB:44:42:3F:80:A5:6B:B3.
|
||||
//
|
||||
//go:embed certs/gsma-rsp2-root-ci1.pem
|
||||
var gsmaRSP2RootCI1PEM []byte
|
||||
|
||||
var gsmaRSP2RootCI1SHA256 = [32]byte{
|
||||
0x5e, 0x3e, 0x91, 0xfd, 0x45, 0x43, 0x27, 0xc3,
|
||||
0xaf, 0x5d, 0x32, 0xa7, 0xa7, 0x3b, 0xbc, 0x59,
|
||||
0xfe, 0x43, 0xaa, 0x7d, 0x85, 0xfd, 0x32, 0xd5,
|
||||
0xdb, 0x44, 0x42, 0x3f, 0x80, 0xa5, 0x6b, 0xb3,
|
||||
}
|
||||
|
||||
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
|
||||
// network half of the LPA download flow: the host authenticates nothing itself
|
||||
// (the eUICC does all certificate verification on-card); it only shuttles the
|
||||
@@ -50,13 +66,28 @@ func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
|
||||
}
|
||||
roots, err := es9pRootCAs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: validated.Host,
|
||||
endpoint: validated,
|
||||
http: netguard.NewPublicHTTPClient(90*time.Second, true),
|
||||
http: netguard.NewPublicHTTPClientWithRootCAs(90*time.Second, true, roots),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func es9pRootCAs() (*x509.CertPool, error) {
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(gsmaRSP2RootCI1PEM) {
|
||||
return nil, errors.New("esim: load GSMA RSP2 Root CI1 certificate")
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
|
||||
// own explanation (surfaced verbatim, as the reference implementation does).
|
||||
type es9pError struct {
|
||||
|
||||
@@ -3,8 +3,11 @@ package device
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -12,6 +15,30 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestES9PRootCAsIncludeGSMARSP2RootCI1(t *testing.T) {
|
||||
roots, err := es9pRootCAs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, _ := pem.Decode(gsmaRSP2RootCI1PEM)
|
||||
if block == nil {
|
||||
t.Fatal("GSMA Root CI1 PEM did not decode")
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual := sha256.Sum256(certificate.Raw); actual != gsmaRSP2RootCI1SHA256 {
|
||||
t.Fatalf("GSMA root SHA-256 = %X, want %X", actual, gsmaRSP2RootCI1SHA256)
|
||||
}
|
||||
if certificate.Subject.CommonName != "GSM Association - RSP2 Root CI1" || !certificate.IsCA {
|
||||
t.Fatalf("unexpected GSMA root certificate: subject=%q ca=%v", certificate.Subject.CommonName, certificate.IsCA)
|
||||
}
|
||||
if _, err := certificate.Verify(x509.VerifyOptions{Roots: roots}); err != nil {
|
||||
t.Fatalf("GSMA root is not trusted by the ES9+ pool: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestES9P routes an es9pClient at a throwaway TLS server.
|
||||
func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
|
||||
t.Helper()
|
||||
|
||||
+58
-3
@@ -829,7 +829,8 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// stays a sibling of A0, directly under BF31.
|
||||
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
|
||||
// disconnect or reverse-proxy timeout must not cancel it halfway through and
|
||||
// skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13).
|
||||
// skip post-commit recovery; EC20 may otherwise remain in SIM failure
|
||||
// (+CME 13).
|
||||
commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
|
||||
payload, err := channel.es10(commitContext, der)
|
||||
cancelCommit()
|
||||
@@ -900,6 +901,32 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
return err
|
||||
}
|
||||
manager.markCachedProfileEnabled(id, iccid)
|
||||
// EnableProfile already requested an eUICC REFRESH. Some AT modems consume
|
||||
// that proactive command and expose the new subscription immediately, so a
|
||||
// full CFUN=1,1 reset would only add downtime. Give those devices a short
|
||||
// chance to prove that their SIM cache is current; modems that keep reporting
|
||||
// the old ICCID continue through the established reboot/recovery path below.
|
||||
if manager.canVerifyProfileSwitchWithoutRestart(id) {
|
||||
probeContext, cancelProbe := context.WithTimeout(
|
||||
context.WithoutCancel(ctx),
|
||||
profileSwitchRefreshProbeTimeout(manager),
|
||||
)
|
||||
probeErr := manager.verifySwitchedICCIDAttempts(probeContext, id, iccid, 3, time.Second)
|
||||
cancelProbe()
|
||||
if probeErr == nil {
|
||||
// Repopulate the cached snapshot while the AT transport is still live.
|
||||
// Verification above is authoritative, so snapshot refresh remains
|
||||
// best-effort just as it is after the legacy reboot path.
|
||||
refreshContext, cancelRefresh := context.WithTimeout(
|
||||
context.WithoutCancel(ctx),
|
||||
manager.longTimeout,
|
||||
)
|
||||
_, _ = manager.Refresh(refreshContext, id)
|
||||
cancelRefresh()
|
||||
manager.unlockESIM()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// The eUICC accepted the target profile. Reset and repopulate the modem in
|
||||
// a detached recovery so it survives an HTTP disconnect, but keep this API
|
||||
// call pending until the live modem ICCID proves that the switch took effect.
|
||||
@@ -1182,13 +1209,41 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
|
||||
return timeout
|
||||
}
|
||||
|
||||
func profileSwitchRefreshProbeTimeout(manager *Manager) time.Duration {
|
||||
// Allow both standard ICCID commands to consume one ordinary command
|
||||
// timeout, plus a small window for the eUICC REFRESH to settle. Keep the
|
||||
// optimisation bounded so an older modem reaches its required reboot soon.
|
||||
timeout := manager.commandTimeout*2 + time.Second
|
||||
if timeout < 3*time.Second {
|
||||
return 3 * time.Second
|
||||
}
|
||||
if timeout > 10*time.Second {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
|
||||
_, native, err := manager.nativeQMIControl(id)
|
||||
return err == nil && !native && !manager.isPCSCDevice(id)
|
||||
}
|
||||
|
||||
// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
|
||||
// result of zero only means the eUICC accepted the operation; the state change
|
||||
// is finalized by REFRESH/reset. The UI must not report success until the modem
|
||||
// is actually exposing the requested ICCID.
|
||||
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
|
||||
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
|
||||
}
|
||||
|
||||
func (manager *Manager) verifySwitchedICCIDAttempts(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
expected string,
|
||||
attempts int,
|
||||
interval time.Duration,
|
||||
) error {
|
||||
expected = strings.TrimSpace(expected)
|
||||
const attempts = 6
|
||||
var lastICCID string
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
@@ -1250,7 +1305,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
|
||||
}
|
||||
if attempt+1 < attempts {
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
case <-time.After(interval):
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
|
||||
}
|
||||
|
||||
@@ -207,6 +207,38 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
||||
const target = "89492026266006792824"
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
|
||||
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
if !manager.canVerifyProfileSwitchWithoutRestart(id) {
|
||||
t.Fatal("AT modem should be eligible for refresh verification before restart")
|
||||
}
|
||||
if err := manager.verifySwitchedICCIDAttempts(context.Background(), id, target, 2, 0); err != nil {
|
||||
t.Fatalf("verifySwitchedICCIDAttempts: %v", err)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestProfileSwitchRefreshProbeTimeoutIsBounded(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
command time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{command: 100 * time.Millisecond, want: 3 * time.Second},
|
||||
{command: 3 * time.Second, want: 7 * time.Second},
|
||||
{command: 30 * time.Second, want: 10 * time.Second},
|
||||
} {
|
||||
manager := &Manager{commandTimeout: test.command}
|
||||
if got := profileSwitchRefreshProbeTimeout(manager); got != test.want {
|
||||
t.Fatalf("command timeout %s: probe timeout = %s, want %s", test.command, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEUMManufacturerForWatchData(t *testing.T) {
|
||||
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
|
||||
t.Fatalf("manufacturer = %q", got)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
const maxHardwareErrorDetail = 1024
|
||||
|
||||
var longHexPayload = regexp.MustCompile(`(?i)\b[0-9a-f]{48,}\b`)
|
||||
|
||||
// HardwareErrorDetail returns a diagnostic error suitable for persistent and
|
||||
// browser-visible logs. AT payloads can contain APDU authentication material,
|
||||
// SMS data, or APN credentials, so CommandError values retain only the command
|
||||
// name and modem final result. Very long hexadecimal payloads from wrapped
|
||||
// protocol errors are removed as a second line of defence.
|
||||
func HardwareErrorDetail(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
detail := redactCommandErrors(err.Error(), err)
|
||||
detail = longHexPayload.ReplaceAllString(detail, "[redacted hex payload]")
|
||||
detail = strings.Map(func(character rune) rune {
|
||||
if unicode.IsControl(character) && character != '\t' && character != '\n' {
|
||||
return ' '
|
||||
}
|
||||
return character
|
||||
}, strings.TrimSpace(detail))
|
||||
runes := []rune(detail)
|
||||
if len(runes) > maxHardwareErrorDetail {
|
||||
detail = string(runes[:maxHardwareErrorDetail]) + "..."
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func redactCommandErrors(detail string, err error) string {
|
||||
if commandErr, ok := err.(*modem.CommandError); ok {
|
||||
detail = strings.ReplaceAll(detail, commandErr.Error(), safeCommandError(commandErr))
|
||||
}
|
||||
switch wrapped := err.(type) {
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, child := range wrapped.Unwrap() {
|
||||
detail = redactCommandErrors(detail, child)
|
||||
}
|
||||
case interface{ Unwrap() error }:
|
||||
if child := wrapped.Unwrap(); child != nil {
|
||||
detail = redactCommandErrors(detail, child)
|
||||
}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func safeCommandError(err *modem.CommandError) string {
|
||||
command := safeATCommandName(err.Command)
|
||||
final := strings.TrimSpace(err.Final)
|
||||
if final == "" {
|
||||
final = "unknown modem error"
|
||||
}
|
||||
return command + " failed: " + final
|
||||
}
|
||||
|
||||
func safeATCommandName(command string) string {
|
||||
command = strings.ToUpper(strings.TrimSpace(command))
|
||||
if command == "" {
|
||||
return "AT command"
|
||||
}
|
||||
if strings.HasPrefix(command, "ATD") {
|
||||
return "ATD"
|
||||
}
|
||||
for index, character := range command {
|
||||
if character == '=' || character == '?' || character == ',' ||
|
||||
character == '"' || unicode.IsSpace(character) {
|
||||
command = command[:index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(command, "AT") || len(command) > 32 {
|
||||
return "AT command"
|
||||
}
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestHardwareErrorDetailRedactsATPayload(t *testing.T) {
|
||||
const payload = "00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"
|
||||
commandErr := &modem.CommandError{
|
||||
Command: `AT+CSIM=78,"` + payload + `"`,
|
||||
Final: "+CME ERROR: 13",
|
||||
Lines: []string{payload},
|
||||
}
|
||||
err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), commandErr))
|
||||
detail := HardwareErrorDetail(err)
|
||||
if strings.Contains(detail, payload) || strings.Contains(detail, "AT+CSIM=") {
|
||||
t.Fatalf("hardware error exposed AT payload: %q", detail)
|
||||
}
|
||||
if !strings.Contains(detail, "select ISIM") || !strings.Contains(detail, "AT+CSIM failed: +CME ERROR: 13") {
|
||||
t.Fatalf("hardware error lost useful diagnostics: %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLogsNewHardwareFailuresWithoutPollingSpam(t *testing.T) {
|
||||
commandError := func() error {
|
||||
return &modem.CommandError{Command: "AT+CSQ", Final: "+CME ERROR: 13"}
|
||||
}
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+CSQ", err: commandError()},
|
||||
{command: "AT+CSQ", err: commandError()},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
||||
{command: "AT+CSQ", err: commandError()},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
hub := loghub.New(nil, 100)
|
||||
manager.logger = slog.New(hub)
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
||||
}
|
||||
if entries := hub.History(10, slog.LevelDebug, ""); len(entries) != 1 {
|
||||
t.Fatalf("continuous failure produced %d log entries, want 1", len(entries))
|
||||
}
|
||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
||||
|
||||
entries := hub.History(10, slog.LevelDebug, "")
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("failure after recovery produced %d total log entries, want 2", len(entries))
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Message != "hardware operation failed" || entry.Fields["device_id"] != id {
|
||||
t.Fatalf("hardware log entry = %#v", entry)
|
||||
}
|
||||
if entry.Fields["error"] != "AT+CSQ failed: +CME ERROR: 13" {
|
||||
t.Fatalf("hardware log detail = %#v", entry.Fields["error"])
|
||||
}
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -21,6 +22,7 @@ type Options struct {
|
||||
SMSTimeout time.Duration
|
||||
ScanTimeout time.Duration
|
||||
CardReaders *pcsc.Service
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -38,6 +40,7 @@ type Manager struct {
|
||||
smsTimeout time.Duration
|
||||
scanTimeout time.Duration
|
||||
cardReaders *pcsc.Service
|
||||
logger *slog.Logger
|
||||
|
||||
qmiRadioOpener qmiRadioSessionOpener
|
||||
nativeQMIRegistrationMu sync.Mutex
|
||||
@@ -120,6 +123,7 @@ func NewManager(options Options) (*Manager, error) {
|
||||
smsTimeout: options.SMSTimeout,
|
||||
scanTimeout: options.ScanTimeout,
|
||||
cardReaders: options.CardReaders,
|
||||
logger: options.Logger,
|
||||
|
||||
qmiRadioOpener: openQMIRadioSession,
|
||||
nativeQMIRegistrationInFlight: make(map[string]struct{}),
|
||||
@@ -373,10 +377,11 @@ func (manager *Manager) setResult(
|
||||
err error,
|
||||
) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.devices[id] != state {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
previousError := state.lastError
|
||||
if snapshot != nil {
|
||||
value := *snapshot
|
||||
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
||||
@@ -388,6 +393,19 @@ func (manager *Manager) setResult(
|
||||
} else {
|
||||
state.lastError = ""
|
||||
}
|
||||
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
|
||||
backend := state.backend
|
||||
hardwareKind := state.candidate.HardwareKind
|
||||
manager.mu.Unlock()
|
||||
if shouldLog {
|
||||
manager.logger.Warn(
|
||||
"hardware operation failed",
|
||||
"device_id", id,
|
||||
"backend", backend,
|
||||
"hardware_kind", hardwareKind,
|
||||
"error", HardwareErrorDetail(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
||||
|
||||
@@ -74,6 +74,17 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) {
|
||||
grouped := MCCsByCountry()
|
||||
if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" {
|
||||
t.Fatalf("GB MCCs = %#v", got)
|
||||
}
|
||||
grouped["GB"][0] = "999"
|
||||
if country, ok := CountryForMCC("234"); !ok || country != "GB" {
|
||||
t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
@@ -110,3 +121,22 @@ func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForSIMRecognizesGiffgaffWithoutRelabelingGenericO2(t *testing.T) {
|
||||
for _, identity := range []CarrierIdentity{
|
||||
{IMSI: "234100000000001", GID1: "508FFFFF", MNCLength: 2},
|
||||
{IMSI: "234100000000001", SPN: "GiffGaff", MNCLength: 2},
|
||||
} {
|
||||
plmn, name, country, ok := CarrierForSIM(identity)
|
||||
if !ok || plmn != "23410" || name != "giffgaff" || country != "GB" {
|
||||
t.Fatalf("giffgaff identity = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
_, name, _, ok := CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "234100000000001", MNCLength: 2,
|
||||
})
|
||||
if !ok || name == "giffgaff" {
|
||||
t.Fatalf("generic O2 SIM was mislabeled as giffgaff: (%q, %v)", name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +548,14 @@ func decodeGSM7(septets []byte) (string, error) {
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// DecodeGSM7Septets decodes a GSM 7-bit default-alphabet string whose septets
|
||||
// are stored one code per byte (the form USSI bodies use when DCS=0x0F). It
|
||||
// returns the decoded text and ok=false if a code is out of range.
|
||||
func DecodeGSM7Septets(data string) (string, bool) {
|
||||
decoded, err := decodeGSM7([]byte(data))
|
||||
return decoded, err == nil
|
||||
}
|
||||
|
||||
type pduCursor struct {
|
||||
data []byte
|
||||
index int
|
||||
@@ -758,13 +766,29 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
byteCount := (int(length) + 1) / 2
|
||||
var byteCount int
|
||||
var septetCount int
|
||||
if toa&0x70 == 0x50 {
|
||||
// 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field
|
||||
// indicates the number of useful semi-octets (i.e. characters * 7 / 4, rounded up).
|
||||
// The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2.
|
||||
// However, some non-standard sources specify length as the direct count of septets
|
||||
// (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes).
|
||||
if length >= 7 {
|
||||
byteCount = (int(length) + 1) / 2
|
||||
septetCount = int(length) * 4 / 7
|
||||
} else {
|
||||
byteCount = (int(length)*7 + 7) / 8
|
||||
septetCount = int(length)
|
||||
}
|
||||
} else {
|
||||
byteCount = (int(length) + 1) / 2
|
||||
}
|
||||
value, err := cursor.bytes(byteCount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if toa&0x70 == 0x50 {
|
||||
septetCount := int(length) * 4 / 7
|
||||
septets, unpackErr := unpackSeptets(value, septetCount, 0)
|
||||
if unpackErr != nil {
|
||||
return "", unpackErr
|
||||
@@ -852,7 +876,13 @@ func decodeUserData(
|
||||
message.Text = string(utf16.Decode(units))
|
||||
return nil
|
||||
default:
|
||||
// 8-bit (binary) user data has no portable text representation, so the
|
||||
// raw payload bytes are rendered as uppercase hexadecimal after the user
|
||||
// data header is stripped. This keeps the bubble non-empty and gives a
|
||||
// faithful rendering of the delivered content rather than a blank "".
|
||||
message.Encoding = SMSEncoding8BitPDU
|
||||
payload := data[headerBytes:]
|
||||
message.Text = strings.ToUpper(hex.EncodeToString(payload))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,3 +263,80 @@ func TestParseCMGLPreservesUndecodableRecord(t *testing.T) {
|
||||
t.Fatalf("messages = %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAlphanumericTPAddress(t *testing.T) {
|
||||
// "TEST" encoded as 4 GSM-7 septets packed into 4 bytes (non-standard septet count format: length=4).
|
||||
cursor := &pduCursor{data: []byte{0x04, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
|
||||
address, err := readTPAddress(cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("readTPAddress error = %v", err)
|
||||
}
|
||||
if address != "TEST" {
|
||||
t.Fatalf("readTPAddress = %q, want TEST", address)
|
||||
}
|
||||
if cursor.index != len(cursor.data) {
|
||||
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAlphanumericTPAddressStandard3GPP(t *testing.T) {
|
||||
// "Google" (6 chars) encoded per 3GPP TS 23.040 §9.1.2.5:
|
||||
// length = 0x0B (11 useful semi-octets), TOA = 0xD0 (Alphanumeric),
|
||||
// 6 bytes payload: C7 F7 FB CC 2E 03
|
||||
cursor := &pduCursor{data: []byte{0x0b, 0xd0, 0xc7, 0xf7, 0xfb, 0xcc, 0x2e, 0x03}}
|
||||
address, err := readTPAddress(cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("readTPAddress standard 3GPP error = %v", err)
|
||||
}
|
||||
if address != "Google" {
|
||||
t.Fatalf("readTPAddress standard 3GPP = %q, want Google", address)
|
||||
}
|
||||
if cursor.index != len(cursor.data) {
|
||||
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
|
||||
}
|
||||
|
||||
// "TEST" (4 chars) with standard 3GPP semi-octets (length = 0x08, 8 semi-octets -> 4 bytes)
|
||||
cursorTest := &pduCursor{data: []byte{0x08, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
|
||||
addressTest, err := readTPAddress(cursorTest)
|
||||
if err != nil {
|
||||
t.Fatalf("readTPAddress standard 3GPP TEST error = %v", err)
|
||||
}
|
||||
if addressTest != "TEST" {
|
||||
t.Fatalf("readTPAddress standard 3GPP TEST = %q, want TEST", addressTest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDeliverPDUWithAlphanumericSender(t *testing.T) {
|
||||
// SMS-DELIVER with alphanumeric originator "VoCat" and empty user data.
|
||||
// SMSC length=0, first octet=0x04, OA length=0x05, OA TON=0xD0,
|
||||
// OA bytes pack "VoCat" (5 septets -> 5 bytes), PID=0x00, DCS=0x00,
|
||||
// SCTS=7 bytes, UDL=0x00.
|
||||
message, err := decodeSMSPDU("000405D0D6F7304C0700004210203040500000")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSMSPDU error = %v", err)
|
||||
}
|
||||
if message.From != "VoCat" {
|
||||
t.Fatalf("From = %q, want VoCat", message.From)
|
||||
}
|
||||
if message.Direction != SMSDirectionReceived {
|
||||
t.Fatalf("Direction = %q", message.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
||||
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
|
||||
// Built from the GSM-7 deliver vector by swapping the DCS to 0xF5
|
||||
// and replacing the user data with three raw binary bytes.
|
||||
message, err := decodeSMSPDU(
|
||||
"000405912143F500F54210203040500003AABBCC",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("decode 8-bit: %v", err)
|
||||
}
|
||||
if message.Encoding != SMSEncoding8BitPDU ||
|
||||
message.Text != "AABBCC" ||
|
||||
message.RawUserData != "AABBCC" {
|
||||
t.Fatalf("8-bit message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -69,7 +69,12 @@ func (manager *Manager) readSnapshot(
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||
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()
|
||||
@@ -186,14 +191,18 @@ func (manager *Manager) readSnapshot(
|
||||
snapshot.RegistrationSource = "COPS"
|
||||
}
|
||||
if snapshot.IMEI == "" {
|
||||
response, ok := optional("AT+CGSN")
|
||||
if ok {
|
||||
snapshot.IMEI = parseIdentifier(
|
||||
response,
|
||||
[]string{"+CGSN:", "+GSN:"},
|
||||
14,
|
||||
17,
|
||||
)
|
||||
// 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) {
|
||||
@@ -211,7 +220,6 @@ func (manager *Manager) readSnapshot(
|
||||
// Preserve a prior successful read across a transient QMI/AT failure.
|
||||
snapshot.IMEI = previousSnapshot.IMEI
|
||||
}
|
||||
|
||||
if response, ok := optional("AT+CFUN?"); ok {
|
||||
if mode, found := parseCFUN(response); found {
|
||||
snapshot.OperatingMode = mode
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package i18n
|
||||
|
||||
// Keep feature-specific diagnostic strings together so additions to the proxy
|
||||
// probe do not cause conflicts in the shared dictionary.
|
||||
func init() {
|
||||
zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall."
|
||||
zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed."
|
||||
zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed."
|
||||
zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed."
|
||||
}
|
||||
+109
-10
@@ -11,7 +11,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const quectelVendorID = "2c7c"
|
||||
const (
|
||||
djiVendorID = "2ca3"
|
||||
dji4GProductID = "4006"
|
||||
// quectelVendorID covers Quectel USB modems exposed purely as serial or
|
||||
// RNDIS/ECM devices (for example the EC200A at 2c7c:6005). Their control
|
||||
// interface is not bound to qmi_wwan, so the QMI-binding gate would skip
|
||||
// them even though they expose a usable AT serial port.
|
||||
quectelVendorID = "2c7c"
|
||||
)
|
||||
|
||||
type SysFSDiscoverer struct {
|
||||
SysRoot string
|
||||
@@ -40,10 +48,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
if os.IsNotExist(err) {
|
||||
entries = nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||
return nil, fmt.Errorf("discover USB QMI modems: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate modems are identified by kernel driver binding instead of a
|
||||
// vendor-ID whitelist. qmi_wwan only binds Qualcomm QMI control interfaces,
|
||||
// so any USB device with a bound interface exposes a live QMI channel. This
|
||||
// keeps discovery vendor-neutral (SIMCom, Sierra, Telit and other
|
||||
// Qualcomm-based modems are found automatically) while MBIM-only devices
|
||||
// stay out, because cdc_mbim binds their control interface instead and the
|
||||
// project has no MBIM backend.
|
||||
qmiBound := d.qmiWWANBoundDevices()
|
||||
|
||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||
devices := make(map[string]*discoveredUSBDevice)
|
||||
for _, entry := range entries {
|
||||
@@ -70,17 +87,25 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
resolvedDevice = devicePath
|
||||
}
|
||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||
if vendorID != quectelVendorID {
|
||||
continue
|
||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
||||
// A bound qmi_wwan interface is the strongest vendor-neutral "this is
|
||||
// a live QMI modem" signal, but it excludes Quectel modules running
|
||||
// in a serial or RNDIS/ECM USB composition (no qmi_wwan binding).
|
||||
// Re-admit them by vendor so their AT serial ports stay discoverable;
|
||||
// the candidate is only kept if a ttyUSB/ttyACM node is actually
|
||||
// found below, which is exactly the AT-bearing composition we want.
|
||||
if !isQuectelUSBModem(vendorID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
state := devices[deviceName]
|
||||
if state == nil {
|
||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||
state = &discoveredUSBDevice{
|
||||
candidate: Candidate{
|
||||
ID: candidateID(productID, serialNumber, deviceName),
|
||||
ID: candidateID(vendorID, productID, serialNumber, deviceName),
|
||||
VendorID: vendorID,
|
||||
ProductID: productID,
|
||||
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
||||
@@ -130,6 +155,22 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
})
|
||||
assignQuectelPortRoles(state.candidate.Ports)
|
||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||
if !state.candidate.HasATPort() {
|
||||
// A modem without a usable AT port cannot be driven by vocat, but it
|
||||
// is far more useful to surface it with a discovery issue than to
|
||||
// silently drop it: the operator sees the device is present and gets
|
||||
// told why it is unusable. Two shapes land here:
|
||||
// * qmi_wwan is bound but no ttyUSB/ttyACM exists — the option/qcserial
|
||||
// driver did not claim the serial interfaces (often a missing PID
|
||||
// in its device-ID table, common on Ubuntu for EG25-G carrier
|
||||
// builds). The modem is alive; it just lacks an AT node.
|
||||
// * no qmi_wwan binding (Quectel re-admitted by vendor) and no AT
|
||||
// port — typically an MBIM/RNDIS/ECM composition. The module is on
|
||||
// the bus but exposes no AT serial interface vocat can open.
|
||||
// Both resolve the same operator action: add the PID to the option
|
||||
// driver or switch the module to a QMI+AT composition.
|
||||
state.candidate.DiscoveryIssue = "at_port_missing"
|
||||
}
|
||||
result = append(result, state.candidate)
|
||||
}
|
||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||
@@ -141,6 +182,23 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
||||
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
||||
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
||||
func IsDJI4GUSB(vendorID, productID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(vendorID), djiVendorID) &&
|
||||
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
||||
}
|
||||
|
||||
// isQuectelUSBModem reports whether a USB identity belongs to a Quectel
|
||||
// module. Quectel's serial/RNDIS/ECM compositions (e.g. EC200A at 2c7c:6005)
|
||||
// do not bind qmi_wwan, so discovery must fall back to the vendor ID to keep
|
||||
// them visible. The candidate is only retained if it exposes an AT serial
|
||||
// port, which filters out unrelated Quectel-branded peripherals.
|
||||
func isQuectelUSBModem(vendorID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID)
|
||||
}
|
||||
|
||||
type discoveredWWANDevice struct {
|
||||
index string
|
||||
ports []Port
|
||||
@@ -235,7 +293,7 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||
}
|
||||
if len(group.ports) > 0 {
|
||||
candidate.ATPort = group.ports[0]
|
||||
candidate.ATPort = selectWWANATPort(group.ports)
|
||||
}
|
||||
if len(group.qmiNames) > 0 {
|
||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||
@@ -246,6 +304,20 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// selectWWANATPort prefers the secondary AT port (…at1) over the primary
|
||||
// (…at0) when both exist, falling back to the first AT port otherwise. Some
|
||||
// Qualcomm MHI modems (notably the UFI dongle behind the OpenStick 410) answer
|
||||
// on at1 immediately while at0 delays every response by 10-20 seconds, so the
|
||||
// secondary port is the usable AT channel.
|
||||
func selectWWANATPort(ports []Port) Port {
|
||||
for _, port := range ports {
|
||||
if port.InterfaceNumber == 1 {
|
||||
return port
|
||||
}
|
||||
}
|
||||
return ports[0]
|
||||
}
|
||||
|
||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||
if !strings.HasPrefix(name, "wwan") {
|
||||
return "", "", 0, false
|
||||
@@ -385,7 +457,34 @@ func readSerialAliases(root string) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func candidateID(productID, serialNumber, usbName string) string {
|
||||
// qmiWWANBoundDevices returns the set of USB device paths (for example "1-6"
|
||||
// or the hub-attached "1-4.3.2") that currently have at least one interface
|
||||
// bound to the kernel's qmi_wwan driver. Interface entries in the driver
|
||||
// directory are named "<device-path>:<interface>.<altsetting>", so the part
|
||||
// before the first colon is the owning USB device. The qmi_wwan driver only
|
||||
// binds Qualcomm QMI control interfaces, so membership doubles as a vendor-
|
||||
// neutral "this is a live QMI modem" signal.
|
||||
func (d *SysFSDiscoverer) qmiWWANBoundDevices() map[string]struct{} {
|
||||
driverRoot := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||
entries, err := os.ReadDir(driverRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
devices := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
// The driver directory also holds control files (bind, unbind, uevent,
|
||||
// module, new_id, ...); only names containing a colon are interfaces.
|
||||
deviceName, _, ok := strings.Cut(entry.Name(), ":")
|
||||
if !ok || deviceName == "" {
|
||||
continue
|
||||
}
|
||||
devices[deviceName] = struct{}{}
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
||||
prefix := "usb-" + sanitizeID(vendorID)
|
||||
serialNumber = strings.TrimSpace(serialNumber)
|
||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||
@@ -394,9 +493,9 @@ func candidateID(productID, serialNumber, usbName string) string {
|
||||
// to the same hub into one entry. Include the physical USB topology in the
|
||||
// discovery key; configured devices remain stable through ATMapper's
|
||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
}
|
||||
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
||||
return prefix + "-" + sanitizeID(productID+"-"+usbName)
|
||||
}
|
||||
|
||||
func sanitizeID(value string) string {
|
||||
|
||||
@@ -2,6 +2,29 @@ package modem
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsDJI4GUSBIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vendorID string
|
||||
productID string
|
||||
want bool
|
||||
}{
|
||||
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
||||
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
||||
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
||||
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
|
||||
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
|
||||
t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
||||
ports := []Port{
|
||||
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||
|
||||
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
||||
candidates, err := discoverer.Discover(context.Background())
|
||||
@@ -57,7 +59,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ID != "quectel-0125-1-6" {
|
||||
if candidate.ID != "usb-2c7c-0125-1-6" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Name != "ttyUSB2" {
|
||||
@@ -101,6 +103,7 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
||||
)
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
@@ -146,6 +149,7 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
||||
}
|
||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||
mustBindQMIWWAN(t, sysRoot, modem.usbName+":1.4")
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
@@ -194,6 +198,7 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||
mustBindQMIWWAN(t, sysRoot, item.usbName+":1.4")
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
@@ -216,9 +221,11 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||
// A plain USB serial adapter (FTDI) exposes ttyUSB but no QMI interface and
|
||||
// is never bound to qmi_wwan, so it must not be treated as a modem.
|
||||
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
||||
@@ -235,6 +242,117 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsNonQuectelVendorBoundToQMIWWAN(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// A Sierra EM7430 flashed to its QMI (rmnet0) composition: non-Quectel
|
||||
// vendor, but its control interface is bound to qmi_wwan.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idVendor"), "1199\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idProduct"), "9077\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "manufacturer"), "Sierra Wireless, Incorporated\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "product"), "EM7430\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-3:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-3:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-3:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.VendorID != "1199" || candidate.Product != "EM7430" {
|
||||
t.Fatalf("candidate = %#v", candidate)
|
||||
}
|
||||
if candidate.ID != "usb-1199-9077-1-3" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if candidate.DiscoveryIssue != "" {
|
||||
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryMarksQMIModemWithoutATPort(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// QMI control interface is bound, but no ttyUSB/ttyACM node exists (for
|
||||
// example the option/qcserial driver does not claim the serial interfaces).
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idProduct"), "0125\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-7:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-7:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||
}
|
||||
if candidate.HasATPort() {
|
||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsHubAttachedQMIWWANDevice(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// Device behind a hub: the USB path "1-4.3.2" contains extra segments, and
|
||||
// the qmi_wwan binding uses the same composite path before the colon.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idProduct"), "0125\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-4.3.2:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-4.3.2:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-4.3.2:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ATPort.Name != "ttyUSB2" {
|
||||
t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if !strings.Contains(candidate.ID, "1-4-3-2") {
|
||||
t.Fatalf("ID = %q, want hub topology in discovery key", candidate.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
@@ -256,7 +374,7 @@ func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||
t.Fatalf("identity = %#v", candidate)
|
||||
}
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at1") || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||
@@ -290,6 +408,23 @@ func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectWWANATPortPrefersSecondaryATPort(t *testing.T) {
|
||||
ports := []Port{
|
||||
{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT},
|
||||
{Name: "wwan0at1", InterfaceNumber: 1, Role: PortRoleAT},
|
||||
}
|
||||
if got := selectWWANATPort(ports); got.Name != "wwan0at1" {
|
||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectWWANATPortFallsBackToPrimaryWhenOnlyAT0(t *testing.T) {
|
||||
ports := []Port{{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT}}
|
||||
if got := selectWWANATPort(ports); got.Name != "wwan0at0" {
|
||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWWANPortName(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, index, kind string
|
||||
@@ -309,6 +444,115 @@ func TestParseWWANPortName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsQuectelSerialModemWithoutQMIWWANBinding(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// A Quectel EC200A in its USB-serial composition (2c7c:6005) exposes ttyUSB
|
||||
// control ports but no qmi_wwan-bound interface, so discovery must re-admit
|
||||
// it by vendor instead of skipping it.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "6005\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-6:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.VendorID != "2c7c" || candidate.ProductID != "6005" {
|
||||
t.Fatalf("candidate = %#v", candidate)
|
||||
}
|
||||
if candidate.ID != "usb-2c7c-6005-1-6" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Name != "ttyUSB2" || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v, want ttyUSB2 at role AT", candidate.ATPort)
|
||||
}
|
||||
if candidate.DiscoveryIssue != "" {
|
||||
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryMarksQuectelPeripheralWithoutATPort(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// A Quectel-branded peripheral exposing only a network interface (no
|
||||
// ttyUSB/ttyACM, no qmi_wwan binding) cannot be driven yet, but vocat
|
||||
// surfaces it with at_port_missing instead of silently dropping it so the
|
||||
// operator sees the device is present and learns what to fix.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-8", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-8", "idProduct"), "6005\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-8:1.0", "net", "enx001122334455"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||
}
|
||||
if candidate.HasATPort() {
|
||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.NetworkInterface != "enx001122334455" {
|
||||
t.Fatalf("network interface = %q", candidate.NetworkInterface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryMarksQuectelMBIMCompositionWithoutATPort(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// An EG25-G in MBIM composition (2c7c:0900) exposes cdc-wdm + net but no
|
||||
// ttyUSB and has no qmi_wwan binding (cdc_mbim binds the control interface
|
||||
// instead). vocat has no MBIM backend, so it must surface the device with
|
||||
// at_port_missing rather than hiding it.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "0900\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "product"), "EG25-G\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "usbmisc", "cdc-wdm0"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "wwp0s20f0u6"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||
}
|
||||
if candidate.HasATPort() {
|
||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.Product != "EG25-G" {
|
||||
t.Fatalf("product = %q", candidate.Product)
|
||||
}
|
||||
// cdc-wdm0 sits under usbmisc/, which scanUSBInterface reports as a QMI
|
||||
// control name; either way the device must appear present, not vanish.
|
||||
if candidate.QMIControl == "" && candidate.NetworkInterface == "" {
|
||||
t.Fatalf("candidate has neither QMI control nor net interface: %#v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
mustMkdir(t, filepath.Dir(path))
|
||||
@@ -323,3 +567,18 @@ func mustMkdir(t *testing.T, path string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustBindQMIWWAN mimics the kernel's driver-binding directory entry: it adds
|
||||
// interfaceName (e.g. "1-6:1.4") under /sys/bus/usb/drivers/qmi_wwan exactly
|
||||
// like the real qmi_wwan driver directory does for a bound QMI interface.
|
||||
func mustBindQMIWWAN(t *testing.T, sysRoot, interfaceName string) {
|
||||
t.Helper()
|
||||
driverDir := filepath.Join(sysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||
if err := os.MkdirAll(driverDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(sysRoot, "bus", "usb", "devices", interfaceName)
|
||||
if err := os.Symlink(target, filepath.Join(driverDir, interfaceName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -147,14 +148,19 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
||||
if err := ctx.Err(); err != nil {
|
||||
return response, err
|
||||
}
|
||||
// Drain the transport before writing the command. Serial transports wait
|
||||
// for any pending output here (a no-op after a synchronous command), while
|
||||
// WWAN transports discard bytes left over from a previous command that
|
||||
// timed out; without this, a late reply (e.g. a slow CGSN response) would
|
||||
// be mis-parsed as this command's output.
|
||||
if err := drainTransport(ctx, session.transport); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||
}
|
||||
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("write %s: %w", command, err)
|
||||
}
|
||||
if err := session.transport.Drain(); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||
}
|
||||
return session.readFinalLocked(ctx, started, command, "", response)
|
||||
}
|
||||
|
||||
@@ -178,7 +184,7 @@ func (session *Session) executePromptLocked(
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("write %s: %w", command, err)
|
||||
}
|
||||
if err := session.transport.Drain(); err != nil {
|
||||
if err := drainTransport(ctx, session.transport); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||
}
|
||||
@@ -203,7 +209,7 @@ func (session *Session) executePromptLocked(
|
||||
response.Duration = time.Since(started)
|
||||
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
||||
}
|
||||
if err := session.transport.Drain(); err != nil {
|
||||
if err := drainTransport(ctx, session.transport); err != nil {
|
||||
session.poisonLocked()
|
||||
response.Duration = time.Since(started)
|
||||
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
||||
@@ -211,6 +217,21 @@ func (session *Session) executePromptLocked(
|
||||
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
||||
}
|
||||
|
||||
// drainTransport retries tcdrain/TCSBRK when the kernel interrupts it with a
|
||||
// signal. go.bug.st/serial already retries EINTR for Read, but its Linux
|
||||
// Drain implementation currently returns the transient error directly.
|
||||
func drainTransport(ctx context.Context, transport Transport) error {
|
||||
for {
|
||||
err := transport.Drain()
|
||||
if !errors.Is(err, syscall.EINTR) {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) readFinalLocked(
|
||||
ctx context.Context,
|
||||
started time.Time,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -27,6 +28,8 @@ type transcriptTransport struct {
|
||||
unexpected error
|
||||
writePartial bool
|
||||
writeEvents chan string
|
||||
drainErrors []error
|
||||
drainCount int
|
||||
}
|
||||
|
||||
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
||||
@@ -114,7 +117,17 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (transport *transcriptTransport) Drain() error { return nil }
|
||||
func (transport *transcriptTransport) Drain() error {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
transport.drainCount++
|
||||
if len(transport.drainErrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := transport.drainErrors[0]
|
||||
transport.drainErrors = transport.drainErrors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (transport *transcriptTransport) ResetInputBuffer() error {
|
||||
transport.mu.Lock()
|
||||
@@ -138,6 +151,31 @@ func (transport *transcriptTransport) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSessionRetriesInterruptedDrain(t *testing.T) {
|
||||
transport := &transcriptTransport{
|
||||
steps: []transportStep{{
|
||||
write: "AT+CSQ\r",
|
||||
chunks: []string{"\r\nAT+CSQ\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||
}},
|
||||
drainErrors: []error{syscall.EINTR},
|
||||
}
|
||||
session, err := NewSession(transport, SessionOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSession() error = %v", err)
|
||||
}
|
||||
|
||||
response, err := session.Execute(context.Background(), "AT+CSQ")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if response.Final != "OK" {
|
||||
t.Fatalf("response final = %q", response.Final)
|
||||
}
|
||||
if transport.drainCount != 2 {
|
||||
t.Fatalf("Drain() calls = %d, want 2", transport.drainCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
||||
transport := &transcriptTransport{steps: []transportStep{{
|
||||
write: "AT+CSQ\r",
|
||||
@@ -415,6 +453,62 @@ func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// drainOrderTransport forwards to an inner Transport while recording
|
||||
// transport-level events, so a test can assert the exact order of Drain and
|
||||
// Write calls.
|
||||
type drainOrderTransport struct {
|
||||
inner Transport
|
||||
events chan string
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Write(payload []byte) (int, error) {
|
||||
transport.events <- "write:" + string(payload)
|
||||
return transport.inner.Write(payload)
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Read(buffer []byte) (int, error) {
|
||||
return transport.inner.Read(buffer)
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Drain() error {
|
||||
transport.events <- "drain"
|
||||
return transport.inner.Drain()
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) ResetInputBuffer() error {
|
||||
return transport.inner.ResetInputBuffer()
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) SetReadTimeout(timeout time.Duration) error {
|
||||
return transport.inner.SetReadTimeout(timeout)
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Close() error {
|
||||
return transport.inner.Close()
|
||||
}
|
||||
|
||||
// WWAN transports discard stale bytes left over from a timed-out command
|
||||
// inside Drain, so the session must call it before writing the next command;
|
||||
// otherwise a late reply (e.g. a slow CGSN response) would be mis-parsed as
|
||||
// the new command's output.
|
||||
func TestSessionDrainsBeforeWritingCommand(t *testing.T) {
|
||||
inner := &transcriptTransport{steps: []transportStep{{
|
||||
write: "AT+CSQ\r",
|
||||
chunks: []string{"\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||
}}}
|
||||
events := make(chan string, 8)
|
||||
session := newTestSession(t, &drainOrderTransport{inner: inner, events: events})
|
||||
if _, err := session.Execute(context.Background(), "AT+CSQ"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if first := <-events; first != "drain" {
|
||||
t.Fatalf("first transport event = %q, want drain before the command write", first)
|
||||
}
|
||||
if second := <-events; second != "write:AT+CSQ\r" {
|
||||
t.Fatalf("second transport event = %q, want the command write", second)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestSession(t *testing.T, transport Transport) *Session {
|
||||
t.Helper()
|
||||
session, err := NewSession(transport, SessionOptions{
|
||||
|
||||
@@ -108,8 +108,27 @@ func (transport *nativeWWANATTransport) Drain() error {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
// WWAN character-device writes are handed to the modem synchronously and
|
||||
// have no termios output queue to drain.
|
||||
return nil
|
||||
// have no termios output queue to drain. A previous command that timed out
|
||||
// can leave late bytes in the input buffer (e.g. a slow CGSN reply that
|
||||
// arrives after the command deadline); discard them here so the next
|
||||
// command starts from a clean stream instead of mis-parsing stale output.
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := unix.Read(transport.fd, buffer); err != nil {
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//go:build linux
|
||||
|
||||
package modem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// TestNativeWWANATTransportDrainDiscardsPendingBytes verifies Drain discards
|
||||
// every byte already buffered on the transport. A command that timed out (e.g.
|
||||
// AT+CGSN on an MHI modem that never answers OK) can leave its late reply in
|
||||
// the input buffer; the next command's Drain must clear it, however much data
|
||||
// is pending, before the session writes the new command.
|
||||
func TestNativeWWANATTransportDrainDiscardsPendingBytes(t *testing.T) {
|
||||
readFD, writeFD := socketpair(t)
|
||||
defer unix.Close(writeFD)
|
||||
|
||||
// More than one 4096-byte Drain read: a slow CGSN reply (echo + IMEI +
|
||||
// trailing CRLF) can exceed a single buffer.
|
||||
payload := make([]byte, 12000)
|
||||
for index := range payload {
|
||||
payload[index] = byte('A' + index%26)
|
||||
}
|
||||
payload = append(payload, []byte("\r\n+CGSN: 357091089453326\r\n")...)
|
||||
if _, err := unix.Write(writeFD, payload); err != nil {
|
||||
t.Fatalf("seed stale bytes: %v", err)
|
||||
}
|
||||
|
||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||
if err := transport.Drain(); err != nil {
|
||||
t.Fatalf("Drain: %v", err)
|
||||
}
|
||||
assertNoPendingBytes(t, readFD, "after Drain")
|
||||
|
||||
// Draining a clean transport is a fast no-op that must not block or error.
|
||||
if err := transport.Drain(); err != nil {
|
||||
t.Fatalf("second Drain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNativeWWANATTransportDrainRejectsClosedTransport covers the guard that
|
||||
// keeps a poisoned session from draining a wedged, already-closed fd.
|
||||
func TestNativeWWANATTransportDrainRejectsClosedTransport(t *testing.T) {
|
||||
readFD, writeFD := socketpair(t)
|
||||
defer unix.Close(writeFD)
|
||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||
if err := transport.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := transport.Drain(); !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("Drain after Close = %v, want ErrClosedPipe", err)
|
||||
}
|
||||
}
|
||||
|
||||
func socketpair(t *testing.T) (int, int) {
|
||||
t.Helper()
|
||||
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fds[0], fds[1]
|
||||
}
|
||||
|
||||
func assertNoPendingBytes(t *testing.T, fd int, context string) {
|
||||
t.Helper()
|
||||
fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("poll %s: %v", context, err)
|
||||
}
|
||||
if ready != 0 {
|
||||
t.Fatalf("%s: fd still readable", context)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package netguard
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -48,6 +49,13 @@ func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url
|
||||
// rejects private/special-use destinations at dial time, and validates every
|
||||
// redirect before following it.
|
||||
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
|
||||
return NewPublicHTTPClientWithRootCAs(timeout, requireHTTPS, nil)
|
||||
}
|
||||
|
||||
// NewPublicHTTPClientWithRootCAs creates the same guarded client while using
|
||||
// the supplied trust pool for protocols whose standards define additional
|
||||
// public roots beyond the host operating system's CA bundle.
|
||||
func NewPublicHTTPClientWithRootCAs(timeout time.Duration, requireHTTPS bool, roots *x509.CertPool) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
@@ -60,6 +68,7 @@ func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: roots,
|
||||
},
|
||||
}
|
||||
return &http.Client{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -44,9 +45,107 @@ func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) {
|
||||
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
|
||||
}
|
||||
|
||||
func ensurePCSCDService(ctx context.Context) {
|
||||
if os.Geteuid() != 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||
_ = exec.CommandContext(ctx, "systemctl", "start", "pcscd.socket").Run()
|
||||
_ = exec.CommandContext(ctx, "systemctl", "start", "pcscd").Run()
|
||||
} else if _, err := os.Stat("/etc/init.d/pcscd"); err == nil {
|
||||
_ = exec.CommandContext(ctx, "/etc/init.d/pcscd", "start").Run()
|
||||
} else if path, err := exec.LookPath("pcscd"); err == nil {
|
||||
_ = exec.CommandContext(ctx, path).Start()
|
||||
}
|
||||
}
|
||||
|
||||
func reauthorizeUSBDevice(sysRoot, usbPath string) {
|
||||
if strings.Contains(usbPath, "..") || strings.Contains(usbPath, "/") || strings.Contains(usbPath, "\\") {
|
||||
return
|
||||
}
|
||||
authPath := filepath.Join(filepath.Clean(sysRoot), "bus", "usb", "devices", usbPath, "authorized")
|
||||
if _, err := os.Stat(authPath); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(authPath, []byte("0\n"), 0o644)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_ = os.WriteFile(authPath, []byte("1\n"), 0o644)
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) waitForPCSCReaders(ctx context.Context, client *pcscdClient, physical []Reader, states []pcscdReaderState) []pcscdReaderState {
|
||||
// First pass: wait up to 2 seconds for active driver negotiation.
|
||||
pollDeadline := time.Now().Add(2 * time.Second)
|
||||
if dl, ok := ctx.Deadline(); ok && dl.Before(pollDeadline) {
|
||||
pollDeadline = dl
|
||||
}
|
||||
for len(states) < len(physical) && time.Now().Before(pollDeadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return states
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
if updated, err := client.readers(ctx); err == nil {
|
||||
states = updated
|
||||
if len(states) >= len(physical) {
|
||||
return states
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(states) >= len(physical) {
|
||||
return states
|
||||
}
|
||||
|
||||
// Second pass: if readers are still missing from pcscd, trigger a USB re-authorization
|
||||
// on the physical devices in sysfs to reset any stalled CCID endpoints, then poll briefly.
|
||||
reauthorized := false
|
||||
for _, phys := range physical {
|
||||
if phys.USBPath != "" {
|
||||
reauthorizeUSBDevice(backend.sysRoot, phys.USBPath)
|
||||
reauthorized = true
|
||||
}
|
||||
}
|
||||
if !reauthorized {
|
||||
return states
|
||||
}
|
||||
|
||||
retryDeadline := time.Now().Add(2 * time.Second)
|
||||
if dl, ok := ctx.Deadline(); ok && dl.Before(retryDeadline) {
|
||||
retryDeadline = dl
|
||||
}
|
||||
for len(states) < len(physical) && time.Now().Before(retryDeadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return states
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if updated, err := client.readers(ctx); err == nil {
|
||||
states = updated
|
||||
if len(states) >= len(physical) {
|
||||
return states
|
||||
}
|
||||
}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil && len(physical) > 0 {
|
||||
ensurePCSCDService(ctx)
|
||||
dialDeadline := time.Now().Add(1500 * time.Millisecond)
|
||||
for time.Now().Before(dialDeadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
if c, dialErr := backend.dial(ctx); dialErr == nil {
|
||||
client, err = c, nil
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if len(physical) > 0 {
|
||||
for index := range physical {
|
||||
@@ -61,6 +160,9 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(physical) > 0 && len(states) < len(physical) {
|
||||
states = backend.waitForPCSCReaders(ctx, client, physical, states)
|
||||
}
|
||||
readers := make([]Reader, 0, len(states))
|
||||
for _, state := range states {
|
||||
reader := Reader{
|
||||
|
||||
@@ -67,17 +67,33 @@ func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader {
|
||||
readers[0] = enrichPCSCReader(readers[0], physical[0])
|
||||
return readers
|
||||
}
|
||||
seen := make(map[string]bool, len(readers))
|
||||
matchedPhysical := make(map[string]bool, len(physical))
|
||||
for i := range readers {
|
||||
seen[readers[i].USBPath] = true
|
||||
for _, usbReader := range physical {
|
||||
if readers[i].USBPath == usbReader.USBPath {
|
||||
readers[i] = enrichPCSCReader(readers[i], usbReader)
|
||||
matchedPhysical[usbReader.USBPath] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Secondary pass: if any pcsc reader is still prefixed with pcsc: (unresolved sysfs USB path),
|
||||
// match with unmatched physical readers by VendorID/ProductID or if 1:1 remaining.
|
||||
var remainingPhysical []Reader
|
||||
for _, p := range physical {
|
||||
if !matchedPhysical[p.USBPath] {
|
||||
remainingPhysical = append(remainingPhysical, p)
|
||||
}
|
||||
}
|
||||
for i := range readers {
|
||||
if strings.HasPrefix(readers[i].USBPath, "pcsc:") && len(remainingPhysical) == 1 {
|
||||
readers[i] = enrichPCSCReader(readers[i], remainingPhysical[0])
|
||||
matchedPhysical[remainingPhysical[0].USBPath] = true
|
||||
remainingPhysical = nil
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, usbReader := range physical {
|
||||
if !seen[usbReader.USBPath] {
|
||||
if !matchedPhysical[usbReader.USBPath] {
|
||||
readers = append(readers, usbReader)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,27 @@ func TestMergePCSCAndSingleUSBReaderEnrichesFallbackPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePCSCAndMultipleUSBReadersWithFallbackPath(t *testing.T) {
|
||||
readers := mergePCSCAndUSBReaders(
|
||||
[]Reader{
|
||||
{Name: "Identiv uTrust 00 00", USBPath: "1-2", CardPresent: true},
|
||||
{Name: "Generic Smart Card Reader 00 00", USBPath: "pcsc:Generic Smart Card Reader 00 00", CardPresent: true},
|
||||
},
|
||||
[]Reader{
|
||||
{Name: "uTrust", USBPath: "1-2", VendorID: "04e6", ProductID: "5810", DiscoveryIssue: "pcsc_driver_missing"},
|
||||
{Name: "ESTKme-RED", USBPath: "1-1", VendorID: "0bda", ProductID: "0165", DiscoveryIssue: "pcsc_driver_missing"},
|
||||
},
|
||||
)
|
||||
if len(readers) != 2 {
|
||||
t.Fatalf("len(readers) = %d, want 2", len(readers))
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.DiscoveryIssue != "" {
|
||||
t.Errorf("reader %#v still has discovery issue %q", r, r.DiscoveryIssue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeUSBTestFile(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
|
||||
+246
-1
@@ -3,6 +3,8 @@ package proxy
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,18 +19,44 @@ type ProbeResult struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
HandshakeOK bool `json:"handshake_ok"`
|
||||
UDPAssociateOK bool `json:"udp_associate_ok"`
|
||||
UDPExchangeOK bool `json:"udp_exchange_ok"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
RelayAddr string `json:"relay_addr,omitempty"`
|
||||
DNSServer string `json:"dns_server,omitempty"`
|
||||
DNSName string `json:"dns_name,omitempty"`
|
||||
DNSRCode int `json:"dns_rcode,omitempty"`
|
||||
RoundTripMS int64 `json:"round_trip_ms,omitempty"`
|
||||
Diagnosis string `json:"diagnosis,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultProbeDNSServer = "1.1.1.1:53"
|
||||
defaultProbeDNSName = "example.com"
|
||||
)
|
||||
|
||||
func ProbeSOCKS5(
|
||||
ctx context.Context,
|
||||
address string,
|
||||
username string,
|
||||
password string,
|
||||
timeout time.Duration,
|
||||
) (ProbeResult, error) {
|
||||
return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName)
|
||||
}
|
||||
|
||||
// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real
|
||||
// UDP DNS round trip through the returned relay. Keeping the target injectable
|
||||
// makes the negative paths deterministic in tests without weakening the
|
||||
// production probe.
|
||||
func probeSOCKS5(
|
||||
ctx context.Context,
|
||||
address string,
|
||||
username string,
|
||||
password string,
|
||||
timeout time.Duration,
|
||||
dnsServer string,
|
||||
dnsName string,
|
||||
) (ProbeResult, error) {
|
||||
address = strings.TrimSpace(address)
|
||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||
@@ -122,11 +150,228 @@ func ProbeSOCKS5(
|
||||
port := int(portBytes[0])<<8 | int(portBytes[1])
|
||||
result.UDPAssociateOK = true
|
||||
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
result.DNSServer = dnsServer
|
||||
result.DNSName = dnsName
|
||||
|
||||
if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil {
|
||||
if result.Diagnosis == "" {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
}
|
||||
if result.Hint == "" {
|
||||
result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
result.Diagnosis = "ready"
|
||||
result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。")
|
||||
result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func probeUDPExchange(
|
||||
ctx context.Context,
|
||||
control net.Conn,
|
||||
result *ProbeResult,
|
||||
relayHost string,
|
||||
relayPort int,
|
||||
dnsServer string,
|
||||
dnsName string,
|
||||
timeout time.Duration,
|
||||
) error {
|
||||
if result == nil {
|
||||
return errors.New("proxy: probe result is nil")
|
||||
}
|
||||
dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer))
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_target"
|
||||
return fmt.Errorf("proxy: resolve UDP probe target: %w", err)
|
||||
}
|
||||
relayHost = strings.TrimSpace(relayHost)
|
||||
if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() {
|
||||
remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String())
|
||||
if splitErr != nil {
|
||||
result.Diagnosis = "invalid_udp_relay"
|
||||
return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr)
|
||||
}
|
||||
relayHost = remoteHost
|
||||
}
|
||||
relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort)))
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_udp_relay"
|
||||
return fmt.Errorf("proxy: resolve UDP relay: %w", err)
|
||||
}
|
||||
|
||||
localNetwork := "udp4"
|
||||
if relayAddress.IP != nil && relayAddress.IP.To4() == nil {
|
||||
localNetwork = "udp6"
|
||||
}
|
||||
udpConnection, err := net.ListenUDP(localNetwork, nil)
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_socket_failed"
|
||||
return fmt.Errorf("proxy: open UDP probe socket: %w", err)
|
||||
}
|
||||
defer udpConnection.Close()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
}
|
||||
if err := udpConnection.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("proxy: set UDP probe deadline: %w", err)
|
||||
}
|
||||
|
||||
query, queryID, err := buildDNSQuery(dnsName)
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_name"
|
||||
return err
|
||||
}
|
||||
datagram, err := buildSOCKSUDPDatagram(dnsAddress, query)
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_target"
|
||||
return err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil {
|
||||
result.Diagnosis = "udp_send_failed"
|
||||
return fmt.Errorf("proxy: send UDP DNS probe: %w", err)
|
||||
}
|
||||
|
||||
responseBuffer := make([]byte, 64*1024)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err)
|
||||
}
|
||||
count, sender, err := udpConnection.ReadFromUDP(responseBuffer)
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err)
|
||||
}
|
||||
if !sameUDPAddress(sender, relayAddress) {
|
||||
continue
|
||||
}
|
||||
payload, err := parseSOCKSUDPDatagram(responseBuffer[:count])
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_invalid_response"
|
||||
return fmt.Errorf("proxy: parse UDP relay response: %w", err)
|
||||
}
|
||||
rcode, err := validateDNSResponse(payload, queryID)
|
||||
if err != nil {
|
||||
result.Diagnosis = "dns_invalid_response"
|
||||
return err
|
||||
}
|
||||
result.UDPExchangeOK = true
|
||||
result.DNSRCode = rcode
|
||||
result.RoundTripMS = time.Since(startedAt).Milliseconds()
|
||||
if result.RoundTripMS < 1 {
|
||||
result.RoundTripMS = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildDNSQuery(name string) ([]byte, uint16, error) {
|
||||
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||
if name == "" || len(name) > 253 {
|
||||
return nil, 0, errors.New("proxy: UDP probe DNS name is invalid")
|
||||
}
|
||||
var idBytes [2]byte
|
||||
if _, err := rand.Read(idBytes[:]); err != nil {
|
||||
return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err)
|
||||
}
|
||||
queryID := binary.BigEndian.Uint16(idBytes[:])
|
||||
query := make([]byte, 12, 12+len(name)+6)
|
||||
binary.BigEndian.PutUint16(query[0:2], queryID)
|
||||
binary.BigEndian.PutUint16(query[2:4], 0x0100)
|
||||
binary.BigEndian.PutUint16(query[4:6], 1)
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return nil, 0, errors.New("proxy: UDP probe DNS label is invalid")
|
||||
}
|
||||
query = append(query, byte(len(label)))
|
||||
query = append(query, label...)
|
||||
}
|
||||
query = append(query, 0, 0, 1, 0, 1)
|
||||
return query, queryID, nil
|
||||
}
|
||||
|
||||
func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||
if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 {
|
||||
return nil, errors.New("proxy: UDP target is invalid")
|
||||
}
|
||||
packet := []byte{0, 0, 0}
|
||||
if ipv4 := target.IP.To4(); ipv4 != nil {
|
||||
packet = append(packet, 1)
|
||||
packet = append(packet, ipv4...)
|
||||
} else if ipv6 := target.IP.To16(); ipv6 != nil {
|
||||
packet = append(packet, 4)
|
||||
packet = append(packet, ipv6...)
|
||||
} else {
|
||||
return nil, errors.New("proxy: UDP target address family is invalid")
|
||||
}
|
||||
packet = append(packet, byte(target.Port>>8), byte(target.Port))
|
||||
packet = append(packet, payload...)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) {
|
||||
if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 {
|
||||
return nil, errors.New("invalid SOCKS5 UDP header")
|
||||
}
|
||||
if packet[2] != 0 {
|
||||
return nil, errors.New("fragmented SOCKS5 UDP response is unsupported")
|
||||
}
|
||||
offset := 4
|
||||
switch packet[3] {
|
||||
case 1:
|
||||
offset += net.IPv4len
|
||||
case 3:
|
||||
if len(packet) <= offset {
|
||||
return nil, errors.New("truncated SOCKS5 UDP domain")
|
||||
}
|
||||
offset += 1 + int(packet[offset])
|
||||
case 4:
|
||||
offset += net.IPv6len
|
||||
default:
|
||||
return nil, errors.New("unsupported SOCKS5 UDP address type")
|
||||
}
|
||||
if offset+2 > len(packet) {
|
||||
return nil, errors.New("truncated SOCKS5 UDP endpoint")
|
||||
}
|
||||
offset += 2
|
||||
if offset >= len(packet) {
|
||||
return nil, errors.New("empty SOCKS5 UDP payload")
|
||||
}
|
||||
return packet[offset:], nil
|
||||
}
|
||||
|
||||
func validateDNSResponse(payload []byte, queryID uint16) (int, error) {
|
||||
if len(payload) < 12 {
|
||||
return 0, errors.New("proxy: DNS response is truncated")
|
||||
}
|
||||
if binary.BigEndian.Uint16(payload[0:2]) != queryID {
|
||||
return 0, errors.New("proxy: DNS response ID does not match")
|
||||
}
|
||||
flags := binary.BigEndian.Uint16(payload[2:4])
|
||||
if flags&0x8000 == 0 {
|
||||
return 0, errors.New("proxy: DNS response is not a response")
|
||||
}
|
||||
rcode := int(flags & 0x000f)
|
||||
if rcode != 0 {
|
||||
return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode)
|
||||
}
|
||||
return rcode, nil
|
||||
}
|
||||
|
||||
func sameUDPAddress(left, right *net.UDPAddr) bool {
|
||||
if left == nil || right == nil || left.Port != right.Port {
|
||||
return false
|
||||
}
|
||||
if left.IP == nil || right.IP == nil {
|
||||
return true
|
||||
}
|
||||
return left.IP.Equal(right.IP)
|
||||
}
|
||||
|
||||
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
||||
switch addressType {
|
||||
case 1:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) {
|
||||
address, stop := startProbeSOCKS5Server(t, false)
|
||||
defer stop()
|
||||
|
||||
result, err := probeSOCKS5(
|
||||
context.Background(),
|
||||
address,
|
||||
"",
|
||||
"",
|
||||
250*time.Millisecond,
|
||||
"192.0.2.53:53",
|
||||
"example.test",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data")
|
||||
}
|
||||
if !result.UDPAssociateOK {
|
||||
t.Fatal("UDP ASSOCIATE should have succeeded")
|
||||
}
|
||||
if result.UDPExchangeOK {
|
||||
t.Fatal("UDP exchange should not be reported as successful")
|
||||
}
|
||||
if result.Diagnosis != "udp_no_roundtrip" {
|
||||
t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) {
|
||||
address, stop := startProbeSOCKS5Server(t, true)
|
||||
defer stop()
|
||||
|
||||
result, err := probeSOCKS5(
|
||||
context.Background(),
|
||||
address,
|
||||
"",
|
||||
"",
|
||||
time.Second,
|
||||
"192.0.2.53:53",
|
||||
"example.test",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Probe returned error: %v", err)
|
||||
}
|
||||
if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK {
|
||||
t.Fatalf("Probe evidence incomplete: %+v", result)
|
||||
}
|
||||
if result.Diagnosis != "ready" {
|
||||
t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis)
|
||||
}
|
||||
if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" {
|
||||
t.Fatalf("Unexpected DNS evidence: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) {
|
||||
t.Helper()
|
||||
udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatalf("ListenUDP: %v", err)
|
||||
}
|
||||
tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
udpConnection.Close()
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
|
||||
if echoDNS {
|
||||
go func() {
|
||||
buffer := make([]byte, 2048)
|
||||
count, sender, readErr := udpConnection.ReadFromUDP(buffer)
|
||||
if readErr != nil || count < 22 {
|
||||
return
|
||||
}
|
||||
// The test target is IPv4, so the SOCKS5 UDP header is ten bytes.
|
||||
buffer[12] = 0x81
|
||||
buffer[13] = 0x80
|
||||
_, _ = udpConnection.WriteToUDP(buffer[:count], sender)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
connection, acceptErr := tcpListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
greeting := make([]byte, 3)
|
||||
if _, readErr := io.ReadFull(connection, greeting); readErr != nil {
|
||||
return
|
||||
}
|
||||
if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil {
|
||||
return
|
||||
}
|
||||
associate := make([]byte, 10)
|
||||
if _, readErr := io.ReadFull(connection, associate); readErr != nil {
|
||||
return
|
||||
}
|
||||
udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port
|
||||
response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)}
|
||||
if _, writeErr := connection.Write(response); writeErr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, connection)
|
||||
}()
|
||||
|
||||
return tcpListener.Addr().String(), func() {
|
||||
_ = tcpListener.Close()
|
||||
_ = udpConnection.Close()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -22,12 +31,42 @@ func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||
"AT+CSQ;+CMSS=7",
|
||||
"AT+CSQ;D12345;",
|
||||
} {
|
||||
if err := validateATCommand(command); err == nil {
|
||||
if err := validateATCommand(command, false); err == nil {
|
||||
t.Errorf("validateATCommand(%q) permitted a guarded mutation", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateATCommandForceBypassesGuard(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, command := range []string{
|
||||
"AT+CGATT=1",
|
||||
"AT+CFUN=1",
|
||||
"AT+CGACT=1,1",
|
||||
"AT+CUSD=1,\"*100#\"",
|
||||
"ATD12345;",
|
||||
} {
|
||||
if err := validateATCommand(command, true); err != nil {
|
||||
t.Errorf("validateATCommand(%q, true): %v", command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateATCommandForceKeepsSyntaxChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, command := range []string{
|
||||
"A",
|
||||
"",
|
||||
"AT\r",
|
||||
"AT\n",
|
||||
string(make([]byte, 513)),
|
||||
} {
|
||||
if err := validateATCommand(command, true); err == nil {
|
||||
t.Errorf("validateATCommand(%q, true) skipped syntax check", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, command := range []string{
|
||||
@@ -39,8 +78,67 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
||||
"AT+CIMI",
|
||||
"AT+CCID",
|
||||
} {
|
||||
if err := validateATCommand(command); err != nil {
|
||||
if err := validateATCommand(command, false); err != nil {
|
||||
t.Errorf("validateATCommand(%q): %v", command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AT terminal must present ERROR / +CME ERROR as a normal response, not as
|
||||
// a 502. Before the CommandError branch was restored, every unsupported or
|
||||
// SIM-less command was folded into "the device operation failed", hiding the
|
||||
// real reason from the user.
|
||||
func TestHandleATSurfacesCommandErrorAsResponse(t *testing.T) {
|
||||
controller := fakeDeviceController{
|
||||
entry: device.Device{ID: "dev1"},
|
||||
atHandler: func(command string) (modem.Response, error) {
|
||||
return modem.Response{}, &modem.CommandError{
|
||||
Command: command,
|
||||
Final: "+CME ERROR: 10",
|
||||
Lines: []string{"+CME ERROR: 10"},
|
||||
}
|
||||
},
|
||||
}
|
||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/devices/dev1/actions/at",
|
||||
strings.NewReader(`{"cmd":"AT+CPIN?","timeout_ms":5000}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if !server.handleAT(recorder, request, "dev1") {
|
||||
t.Fatal("handleAT returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
response, _ := data["response"].(string)
|
||||
if !strings.Contains(response, "+CME ERROR: 10") {
|
||||
t.Fatalf("response = %q, want +CME ERROR text", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleATMapsNonCommandErrorTo502(t *testing.T) {
|
||||
controller := fakeDeviceController{
|
||||
entry: device.Device{ID: "dev1"},
|
||||
atErr: errors.New("transport wedged"),
|
||||
}
|
||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/devices/dev1/actions/at",
|
||||
strings.NewReader(`{"cmd":"AT+CSQ"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if !server.handleAT(recorder, request, "dev1") {
|
||||
t.Fatal("handleAT returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
||||
}, "\n"),
|
||||
Time: run.FinishedAt, Task: task, Run: run,
|
||||
}
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
@@ -91,6 +91,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
callDeduplicationWindow = 60 * time.Second
|
||||
cellularCallMonitorInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
callDeduplicationMu sync.Mutex
|
||||
callDeduplicationMap = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
type IncomingCallNotification struct {
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
DeviceLabel string
|
||||
Caller string
|
||||
Called string
|
||||
Time time.Time
|
||||
Environment string
|
||||
}
|
||||
|
||||
func (value IncomingCallNotification) Title() string {
|
||||
return "收到来电"
|
||||
}
|
||||
|
||||
func (value IncomingCallNotification) Text() string {
|
||||
envText := "VoWiFi"
|
||||
if value.Environment == "cellular" {
|
||||
envText = "基站直连"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"📞 收到来电",
|
||||
"设备 " + value.DeviceLabel,
|
||||
"来电号码 " + value.Caller,
|
||||
"被呼号码 " + value.Called,
|
||||
"时间 " + value.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
"网络 " + envText,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (value IncomingCallNotification) DetailText() string {
|
||||
lines := strings.Split(value.Text(), "\n")
|
||||
return strings.Join(lines[1:], "\n")
|
||||
}
|
||||
|
||||
func shouldSuppressDuplicateCall(key string, now time.Time, window time.Duration) bool {
|
||||
callDeduplicationMu.Lock()
|
||||
defer callDeduplicationMu.Unlock()
|
||||
for k, t := range callDeduplicationMap {
|
||||
if now.Sub(t) > window*2 {
|
||||
delete(callDeduplicationMap, k)
|
||||
}
|
||||
}
|
||||
if lastTime, exists := callDeduplicationMap[key]; exists {
|
||||
if now.Sub(lastTime) < window {
|
||||
return true
|
||||
}
|
||||
}
|
||||
callDeduplicationMap[key] = now
|
||||
return false
|
||||
}
|
||||
|
||||
// NotifyIncomingCall delivers an incoming call alert to all configured notification channels.
|
||||
func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCallNotification) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
caller := strings.TrimSpace(notification.Caller)
|
||||
if caller == "" {
|
||||
caller = "未知号码"
|
||||
}
|
||||
notification.Caller = caller
|
||||
|
||||
called := strings.TrimSpace(notification.Called)
|
||||
if called == "" {
|
||||
called = "--"
|
||||
}
|
||||
notification.Called = called
|
||||
|
||||
if notification.Time.IsZero() {
|
||||
notification.Time = time.Now().UTC()
|
||||
}
|
||||
|
||||
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
||||
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if notification.DeviceLabel == "" || notification.DeviceLabel == "--" {
|
||||
if configured, err := s.store.Device(ctx, notification.DeviceID); err == nil {
|
||||
notification.DeviceName = strings.TrimSpace(configured.Name)
|
||||
notification.DeviceLabel = firstNonEmpty(configured.Name, configured.ID, "--")
|
||||
} else {
|
||||
notification.DeviceLabel = firstNonEmpty(notification.DeviceID, "--")
|
||||
}
|
||||
}
|
||||
|
||||
destCtx := s.notificationDestinationContext(ctx)
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||
setting, err := s.store.NotificationSetting(destCtx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("read incoming call notification setting", "channel", channel, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(setting.Config, &config); err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("decode incoming call notification setting", "channel", channel, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendCallNotification(ctx context.Context, channel string, config map[string]any, message IncomingCallNotification) error {
|
||||
switch channel {
|
||||
case "telegram":
|
||||
return sendTelegramTextNotification(ctx, config, message.Text())
|
||||
case "bark":
|
||||
return sendBarkTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||
case "email":
|
||||
return sendEmailTextNotification(ctx, config, message.Title()+" - "+message.DeviceLabel, message.Text())
|
||||
case "pushplus":
|
||||
return sendPushplusTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||
case "webhook":
|
||||
return sendCallWebhookNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomCallValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkCallValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func renderCallWebhookTemplate(template string, message IncomingCallNotification) string {
|
||||
rendered := message.Text()
|
||||
if strings.TrimSpace(template) != "" {
|
||||
replacements := map[string]string{
|
||||
"{{text}}": rendered,
|
||||
"{{content}}": message.DetailText(),
|
||||
"{{event}}": "call.received",
|
||||
"{{timestamp}}": message.Time.UTC().Format(time.RFC3339),
|
||||
"{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
"{{number}}": message.Caller,
|
||||
"{{caller}}": message.Caller,
|
||||
"{{called}}": message.Called,
|
||||
"{{device_id}}": message.DeviceID,
|
||||
"{{device_name}}": message.DeviceName,
|
||||
"{{device_label}}": message.DeviceLabel,
|
||||
"{{environment}}": message.Environment,
|
||||
}
|
||||
for placeholder, value := range replacements {
|
||||
template = strings.ReplaceAll(template, placeholder, value)
|
||||
}
|
||||
return template
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func sendCallWebhookNotification(ctx context.Context, config map[string]any, message IncomingCallNotification) error {
|
||||
template := configString(config, "text_template")
|
||||
rendered := renderCallWebhookTemplate(template, message)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": "call.received",
|
||||
"message": rendered,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"caller": message.Caller,
|
||||
"called": message.Called,
|
||||
"environment": message.Environment,
|
||||
})
|
||||
timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
|
||||
client, err := restrictedHTTPClient(ctx, timeout, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
retries := configInt(config, "retry_max")
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sendErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if requestErr != nil {
|
||||
return fmt.Errorf("create call webhook notification request: %w", requestErr)
|
||||
}
|
||||
for name, value := range configStringMap(config, "headers") {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "vocat-call-notification/1")
|
||||
if secret := configString(config, "secret"); secret != "" {
|
||||
signature := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = signature.Write(payload)
|
||||
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
|
||||
}
|
||||
sendErr = performNotificationRequest(client, request, false)
|
||||
if sendErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sendErr != nil {
|
||||
return sendErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomCallValues(message IncomingCallNotification) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "call.received",
|
||||
"title": message.Title(),
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.DetailText(),
|
||||
"number": message.Caller,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func larkCallValues(message IncomingCallNotification) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "call.received",
|
||||
"title": message.Title(),
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.DetailText(),
|
||||
"number": message.Caller,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
// StartCellularCallMonitor scans physical modems for incoming calls in cellular mode.
|
||||
func (s *Server) StartCellularCallMonitor(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ticker := time.NewTicker(cellularCallMonitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.pollCellularCalls(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pollCellularCalls(ctx context.Context) {
|
||||
devices, err := s.store.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, config := range devices {
|
||||
if !config.NetworkEnabled {
|
||||
continue
|
||||
}
|
||||
// If VoWiFi is active, incoming calls are handled directly by SIP INVITE in real time.
|
||||
if s.callTransport(config.ID) == "vowifi" {
|
||||
continue
|
||||
}
|
||||
entry, physicalID, present := s.physicalForConfig(config)
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
pollCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
response, err := s.devices.ExecuteAT(pollCtx, physicalID, "AT+CLCC")
|
||||
cancel()
|
||||
if err != nil || !response.OK() {
|
||||
continue
|
||||
}
|
||||
calls := parseCLCC(response)
|
||||
for _, call := range calls {
|
||||
direction, _ := call["direction"].(int)
|
||||
state, _ := call["state"].(int)
|
||||
// direction 1 = incoming (Mobile Terminated)
|
||||
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
|
||||
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
|
||||
caller, _ := call["number"].(string)
|
||||
if caller == "" {
|
||||
caller = "未知号码"
|
||||
}
|
||||
called := ""
|
||||
if entry.Snapshot != nil {
|
||||
called = entry.Snapshot.Phone.Number
|
||||
}
|
||||
s.NotifyIncomingCall(ctx, IncomingCallNotification{
|
||||
DeviceID: config.ID,
|
||||
DeviceName: strings.TrimSpace(config.Name),
|
||||
DeviceLabel: firstNonEmpty(config.Name, config.ID, "--"),
|
||||
Caller: caller,
|
||||
Called: firstNonEmpty(called, "--"),
|
||||
Time: time.Now().UTC(),
|
||||
Environment: "cellular",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||
notification := IncomingCallNotification{
|
||||
DeviceID: "ec20-1",
|
||||
DeviceName: "Main Router",
|
||||
DeviceLabel: "Main Router",
|
||||
Caller: "+8613800138000",
|
||||
Called: "+8613900139000",
|
||||
Time: now,
|
||||
Environment: "vowifi",
|
||||
}
|
||||
|
||||
if notification.Title() != "收到来电" {
|
||||
t.Errorf("Title() = %q, want '收到来电'", notification.Title())
|
||||
}
|
||||
|
||||
text := notification.Text()
|
||||
for _, want := range []string{
|
||||
"📞 收到来电",
|
||||
"设备 Main Router",
|
||||
"来电号码 +861380138000"[:10],
|
||||
"被呼号码 +8613900139000",
|
||||
"网络 VoWiFi",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("Text() omitted %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
notification.Environment = "cellular"
|
||||
if !strings.Contains(notification.Text(), "网络 基站直连") {
|
||||
t.Errorf("Text() in cellular mode omitted '网络 基站直连':\n%s", notification.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingCallDeduplication(t *testing.T) {
|
||||
now := time.Now()
|
||||
key := "test-device:+8613800000000"
|
||||
|
||||
// First call should not be suppressed
|
||||
if shouldSuppressDuplicateCall(key, now, time.Minute) {
|
||||
t.Fatal("first call unexpectedly suppressed")
|
||||
}
|
||||
|
||||
// Immediate duplicate should be suppressed
|
||||
if !shouldSuppressDuplicateCall(key, now.Add(5*time.Second), time.Minute) {
|
||||
t.Fatal("duplicate call within window was not suppressed")
|
||||
}
|
||||
|
||||
// Call after window should be allowed
|
||||
if shouldSuppressDuplicateCall(key, now.Add(70*time.Second), time.Minute) {
|
||||
t.Fatal("call after window was suppressed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCallWebhookTemplate(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||
message := IncomingCallNotification{
|
||||
DeviceID: "dev-1",
|
||||
DeviceName: "Living Room",
|
||||
DeviceLabel: "EC20",
|
||||
Caller: "+8613800000000",
|
||||
Called: "+8613900000000",
|
||||
Time: now,
|
||||
Environment: "vowifi",
|
||||
}
|
||||
|
||||
got := renderCallWebhookTemplate("{{event}}|{{device_id}}|{{device_name}}|{{device_label}}|{{caller}}|{{called}}|{{environment}}", message)
|
||||
want := "call.received|dev-1|Living Room|EC20|+8613800000000|+8613900000000|vowifi"
|
||||
if got != want {
|
||||
t.Fatalf("renderCallWebhookTemplate() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAndLarkCallValues(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*60*60)
|
||||
now := time.Date(2026, 8, 20, 18, 0, 0, 0, location)
|
||||
message := IncomingCallNotification{
|
||||
DeviceID: "dev-1",
|
||||
DeviceName: "Office",
|
||||
DeviceLabel: "EC20-Office",
|
||||
Caller: "+8613800138000",
|
||||
Called: "+8613900139000",
|
||||
Time: now,
|
||||
Environment: "cellular",
|
||||
}
|
||||
|
||||
wecom := wecomCallValues(message)
|
||||
if wecom["event"] != "call.received" || wecom["title"] != "收到来电" || wecom["number"] != "+8613800138000" {
|
||||
t.Fatalf("wecomCallValues = %#v", wecom)
|
||||
}
|
||||
if !strings.Contains(wecom["message"], "网络 基站直连") {
|
||||
t.Fatalf("wecomCallValues message omitted network: %s", wecom["message"])
|
||||
}
|
||||
|
||||
lark := larkCallValues(message)
|
||||
if lark["event"] != "call.received" || lark["title"] != "收到来电" || lark["device_label"] != "EC20-Office" {
|
||||
t.Fatalf("larkCallValues = %#v", lark)
|
||||
}
|
||||
}
|
||||
+209
-60
@@ -142,6 +142,9 @@ func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
||||
return true
|
||||
case "dashboard/host":
|
||||
s.handleDashboardHost(w, r)
|
||||
return true
|
||||
case "devices":
|
||||
return s.handleDevices(w, r)
|
||||
case "devices/discovered":
|
||||
@@ -387,6 +390,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
result = append(result, map[string]any{
|
||||
"hardware_kind": candidate.HardwareKind,
|
||||
"reader_name": candidate.ReaderName,
|
||||
"device_type": discoveredDeviceType(candidate),
|
||||
"discovery_key": entry.ID,
|
||||
"control_path": controlPath,
|
||||
"net_interface": candidate.NetworkInterface,
|
||||
@@ -577,7 +581,7 @@ func (s *Server) handleDevicePath(
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleUSSD(w, r, physicalID)
|
||||
return s.handleUSSD(w, r, config, physicalID)
|
||||
case "actions/ussd/continue":
|
||||
return s.handleUSSDContinue(w, r)
|
||||
case "actions/ussd/cancel":
|
||||
@@ -1041,13 +1045,14 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
var request struct {
|
||||
Command string `json:"cmd"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
command := strings.TrimSpace(request.Command)
|
||||
if err := validateATCommand(command); err != nil {
|
||||
if err := validateATCommand(command, request.Force); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "unsafe_at_command", err.Error())
|
||||
return true
|
||||
}
|
||||
@@ -1055,6 +1060,26 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
defer cancel()
|
||||
response, err := s.devices.ExecuteAT(ctx, id, command)
|
||||
if err != nil {
|
||||
var commandErr *modem.CommandError
|
||||
if errors.As(err, &commandErr) {
|
||||
// The modem answered with ERROR / +CME ERROR. An AT terminal must
|
||||
// surface that text (including the CME detail) as a normal response;
|
||||
// folding it into a 502 hides the real reason from the user.
|
||||
text := strings.Join(commandErr.Lines, "\n")
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += commandErr.Final
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"response": text,
|
||||
"final": commandErr.Final,
|
||||
"duration_ms": 0,
|
||||
"urcs": []string{},
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
@@ -1076,7 +1101,7 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
return true
|
||||
}
|
||||
|
||||
func validateATCommand(command string) error {
|
||||
func validateATCommand(command string, force bool) error {
|
||||
upper := strings.ToUpper(command)
|
||||
if len(command) < 2 || len(command) > 512 || !strings.HasPrefix(upper, "AT") {
|
||||
return errors.New("AT command must start with AT and contain at most 512 characters")
|
||||
@@ -1084,6 +1109,9 @@ func validateATCommand(command string) error {
|
||||
if strings.ContainsAny(command, "\r\n\x00") {
|
||||
return errors.New("AT command must contain exactly one line")
|
||||
}
|
||||
if force {
|
||||
return nil
|
||||
}
|
||||
canonical := strings.NewReplacer(" ", "", "\t", "").Replace(upper)
|
||||
for _, blocked := range []string{
|
||||
`+QCFG="USBNET"`,
|
||||
@@ -1114,7 +1142,34 @@ func validateATCommand(command string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) bool {
|
||||
// imsUSSIController is the optional VoWiFi runtime capability used to route a
|
||||
// USSD request over IMS (3GPP TS 24.390) when VoWiFi is enabled and the IMS
|
||||
// session is registered. device.Manager does not implement it; the VoWiFi
|
||||
// runtime manager does.
|
||||
type imsUSSIController interface {
|
||||
SendUSSI(context.Context, string, vowifi.USSISubmitRequest) (vowifi.USSISubmitResult, error)
|
||||
}
|
||||
|
||||
// openUSSDSession mirrors device.Manager.openUSSDSession but lives on the HTTP
|
||||
// server so a USSI awaiting-input reply can hand back a token the existing
|
||||
// continue/cancel endpoints understand. The token is only a device handle;
|
||||
// the IMS session owns the actual dialog.
|
||||
func (s *Server) openUSSDSession(deviceID string) string {
|
||||
return s.ussdSessions.open(deviceID)
|
||||
}
|
||||
|
||||
// ussdSessionDevice resolves a USSD session token created by openUSSDSession
|
||||
// back to its device id, matching device.ErrUSSDSessionNotFound semantics.
|
||||
func (s *Server) ussdSessionDevice(sessionID string) (string, error) {
|
||||
return s.ussdSessions.device(sessionID)
|
||||
}
|
||||
|
||||
// dropUSSDSession releases a USSD session token.
|
||||
func (s *Server) dropUSSDSession(sessionID string) {
|
||||
s.ussdSessions.drop(sessionID)
|
||||
}
|
||||
|
||||
func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, config store.Device, id string) bool {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
@@ -1128,21 +1183,92 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) b
|
||||
}
|
||||
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
||||
defer cancel()
|
||||
|
||||
cmd := strings.TrimSpace(request.Command)
|
||||
if cmd == "*#06#" || cmd == "*#06" {
|
||||
imei := config.ModemIMEI
|
||||
if imei == "" {
|
||||
if runtime, runtimeErr := s.store.DeviceRuntime(ctx, id); runtimeErr == nil {
|
||||
imei = runtime.IMEI
|
||||
}
|
||||
}
|
||||
if imei != "" {
|
||||
writeUSSDResult(w, device.USSDResult{
|
||||
Text: fmt.Sprintf("IMEI: %s", imei),
|
||||
Status: "final",
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
if cmd == "*#0000#" || cmd == "*#0000" {
|
||||
firmware := ""
|
||||
if runtime, runtimeErr := s.store.DeviceRuntime(ctx, id); runtimeErr == nil {
|
||||
firmware = runtime.Firmware
|
||||
}
|
||||
if firmware != "" {
|
||||
writeUSSDResult(w, device.USSDResult{
|
||||
Text: fmt.Sprintf("Software Version: %s", firmware),
|
||||
Status: "final",
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// VoWiFi-first: when VoWiFi owns the radio the cellular CUSD path has no
|
||||
// network to talk to (CFUN=4 returns +CME ERROR: 30). Route over IMS/USSI
|
||||
// when the IMS session is registered, and fall back to cellular CUSD only
|
||||
// when USSI is not ready or the runtime is unavailable.
|
||||
if config.VoWiFiEnabled && s.vowifi != nil {
|
||||
sender, canSendIMS := s.vowifi.(imsUSSIController)
|
||||
if canSendIMS {
|
||||
if state, stateErr := s.vowifi.State(id); stateErr == nil && state.IMSReady {
|
||||
result, sendErr := sender.SendUSSI(ctx, id, vowifi.USSISubmitRequest{Code: request.Command})
|
||||
if sendErr == nil {
|
||||
writeUSSDResult(w, ussdResultFromUSSI(result, id, s))
|
||||
return true
|
||||
}
|
||||
if !errors.Is(sendErr, vowifi.ErrUSSINotReady) {
|
||||
s.writeDeviceError(w, sendErr)
|
||||
return true
|
||||
}
|
||||
// ErrUSSINotReady: fall through to cellular CUSD.
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := s.devices.USSD(ctx, id, request.Command)
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"result": result.Text,
|
||||
"raw": result.Raw,
|
||||
"dcs": result.DCS,
|
||||
},
|
||||
})
|
||||
writeUSSDResult(w, result)
|
||||
return true
|
||||
}
|
||||
|
||||
// ussdResultFromUSSI maps a USSI result onto the device.USSDResult shape that
|
||||
// writeUSSDResult expects. A USSI awaiting-input reply opens a server-side
|
||||
// session token via the device manager so the existing continue/cancel
|
||||
// endpoints keep working; the token maps back to the device and the continue
|
||||
// handler re-enters the USSI path through the same imsUSSIController.
|
||||
func ussdResultFromUSSI(result vowifi.USSISubmitResult, deviceID string, server *Server) device.USSDResult {
|
||||
mapped := device.USSDResult{
|
||||
Text: result.Text,
|
||||
Raw: result.Raw,
|
||||
DCS: result.DCS,
|
||||
Status: result.Status,
|
||||
Continueable: result.Continueable,
|
||||
}
|
||||
// USSI has no inline continue/terminate flag in the 2xx response body, so
|
||||
// treat any non-empty successful reply as potentially multi-round. The cancel
|
||||
// endpoint drops the local token; the network will time the dialog out if it
|
||||
// was actually final.
|
||||
if mapped.Status != "failed" && mapped.Status != "terminated" && mapped.Text != "" {
|
||||
mapped.Status = "awaiting_input"
|
||||
mapped.Continueable = true
|
||||
mapped.SessionID = server.openUSSDSession(deviceID)
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
|
||||
if !requireMethod(w, r, http.MethodPatch) {
|
||||
return true
|
||||
@@ -1421,9 +1547,9 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
||||
default:
|
||||
// Device errors may echo an AT command. Authentication commands can
|
||||
// contain APN credentials, so keep raw errors out of logs and responses.
|
||||
s.logger.Warn("device operation failed")
|
||||
// Preserve the hardware failure reason in the operator-visible log while
|
||||
// keeping AT payloads and long APDU material out of it.
|
||||
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
|
||||
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
||||
}
|
||||
}
|
||||
@@ -1612,7 +1738,14 @@ func (s *Server) configuredDeviceOverview(
|
||||
result["id"] = config.ID
|
||||
result["name"] = config.Name
|
||||
result["interface"] = config.Interface
|
||||
result["at_port"] = config.ATPort
|
||||
// ttyUSB allocation changes across USB reconnects and boot cycles. The AT
|
||||
// terminal must use only the currently discovered physical port; a stored
|
||||
// path may point at another modem after enumeration order changes.
|
||||
liveATPort := ""
|
||||
if present {
|
||||
liveATPort = entry.Candidate.ATPort.OpenPath()
|
||||
}
|
||||
result["at_port"] = liveATPort
|
||||
result["audio_device"] = config.AudioDevice
|
||||
result["backend_mode"] = config.DeviceBackend
|
||||
result["control_device"] = config.ControlDevice
|
||||
@@ -1686,56 +1819,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
||||
enabled, _ := extra["enabled"].(bool)
|
||||
active, _ := extra["active"].(bool)
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"carrier_profile": extra["carrier_profile"],
|
||||
"carrier_profile_from": extra["carrier_profile_from"],
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
}
|
||||
}
|
||||
|
||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"carrier_profile": runtime.CarrierProfile,
|
||||
"carrier_profile_from": runtime.CarrierProfileFrom,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": map[string]any{
|
||||
"established": runtime.TunnelReady,
|
||||
"name": runtime.TunnelName,
|
||||
@@ -1909,6 +2046,8 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
config.NetworkEnabled = false
|
||||
config.SMSEnabled = true
|
||||
config.VoWiFiEnabled = true
|
||||
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||
config.DeviceType = store.DeviceTypeDJI4G
|
||||
}
|
||||
if config.Interface == "" {
|
||||
config.Interface = candidate.NetworkInterface
|
||||
@@ -1933,6 +2072,16 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
}
|
||||
}
|
||||
|
||||
func discoveredDeviceType(candidate modem.Candidate) string {
|
||||
if candidate.HardwareKind == "pcsc" {
|
||||
return store.DeviceTypeUSBSIMReader
|
||||
}
|
||||
if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||
return store.DeviceTypeDJI4G
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||
if snapshot == nil {
|
||||
return map[string]any{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
// overviewStreamInterval is the cadence at which the overview SSE stream pushes
|
||||
@@ -192,6 +193,31 @@ func (s *Server) handleUSSDContinue(w http.ResponseWriter, r *http.Request) bool
|
||||
input := firstNonEmpty(request.Input, request.Command)
|
||||
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
||||
defer cancel()
|
||||
// A session opened by the USSI path maps back to a device id that may still
|
||||
// be VoWiFi-active. Prefer USSI continue when IMS is ready; otherwise report
|
||||
// the session as unavailable rather than falling through to the cellular
|
||||
// CUSD path, because the IMS session owns the actual dialog.
|
||||
if deviceID, sessionErr := s.ussdSessionDevice(sessionID); sessionErr == nil {
|
||||
if config, configErr := s.store.Device(r.Context(), deviceID); configErr == nil &&
|
||||
config.VoWiFiEnabled && s.vowifi != nil {
|
||||
if sender, ok := s.vowifi.(imsUSSIController); ok {
|
||||
if state, stateErr := s.vowifi.State(deviceID); stateErr == nil && state.IMSReady {
|
||||
result, sendErr := sender.SendUSSI(ctx, deviceID, vowifi.USSISubmitRequest{Input: input})
|
||||
if sendErr == nil {
|
||||
writeUSSDResult(w, ussdResultFromUSSI(result, deviceID, s))
|
||||
return true
|
||||
}
|
||||
if !errors.Is(sendErr, vowifi.ErrUSSINotReady) {
|
||||
s.writeDeviceError(w, sendErr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, "ussi_session_unavailable",
|
||||
"USSI session is no longer available because the IMS registration has dropped")
|
||||
return true
|
||||
}
|
||||
result, err := s.devices.ContinueUSSD(ctx, sessionID, input)
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
@@ -219,6 +245,16 @@ func (s *Server) handleUSSDCancel(w http.ResponseWriter, r *http.Request) bool {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required")
|
||||
return true
|
||||
}
|
||||
// Drop a USSI-originated session token locally. USSI has no network-side
|
||||
// release signalling in the minimal implementation, so dropping the handle
|
||||
// matches the cellular AT+CUSD=2 "best-effort abort" behavior.
|
||||
if _, sessionErr := s.ussdSessionDevice(sessionID); sessionErr == nil {
|
||||
s.dropUSSDSession(sessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{"cancelled": true, "session_id": sessionID},
|
||||
})
|
||||
return true
|
||||
}
|
||||
if err := s.devices.CancelUSSD(r.Context(), sessionID); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
@@ -266,6 +267,143 @@ func TestHandleUSSDContinueRequiresSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeUSSIController implements both VoWiFiController and the optional
|
||||
// imsUSSIController interface so the HTTP layer USSI path can be exercised
|
||||
// without a real runtime manager.
|
||||
type fakeUSSIController struct {
|
||||
fakeVoWiFiController
|
||||
sendErr error
|
||||
sendResult vowifi.USSISubmitResult
|
||||
sendCalled int
|
||||
lastInput string
|
||||
}
|
||||
|
||||
func (controller *fakeUSSIController) SendUSSI(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
request vowifi.USSISubmitRequest,
|
||||
) (vowifi.USSISubmitResult, error) {
|
||||
controller.sendCalled++
|
||||
controller.lastInput = request.Input
|
||||
if request.Code != "" {
|
||||
controller.lastInput = request.Code
|
||||
}
|
||||
return controller.sendResult, controller.sendErr
|
||||
}
|
||||
|
||||
func TestHandleUSSDRoutesOverIMSWhenReady(t *testing.T) {
|
||||
controller := &fakeUSSIController{
|
||||
fakeVoWiFiController: fakeVoWiFiController{state: vowifi.State{IMSReady: true}},
|
||||
sendResult: vowifi.USSISubmitResult{Status: "final", Text: "IMS balance"},
|
||||
}
|
||||
devices := fakeDeviceController{ussdResult: device.USSDResult{Status: "final", Text: "cellular"}}
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
devices: devices,
|
||||
vowifi: controller,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd", strings.NewReader(`{"command":"*100#"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSD(recorder, request, store.Device{ID: "dev1", VoWiFiEnabled: true}, "dev1")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
result, _ := data["result"].(map[string]any)
|
||||
if result["text"] != "IMS balance" {
|
||||
t.Fatalf("result = %v, want IMS routed response", result)
|
||||
}
|
||||
if controller.sendCalled != 1 {
|
||||
t.Fatalf("SendUSSI called %d times, want 1", controller.sendCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUSSDFallsBackToCellularWhenIMSNotReady(t *testing.T) {
|
||||
controller := &fakeUSSIController{
|
||||
fakeVoWiFiController: fakeVoWiFiController{state: vowifi.State{}},
|
||||
}
|
||||
devices := fakeDeviceController{ussdResult: device.USSDResult{Status: "final", Text: "cellular"}}
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
devices: devices,
|
||||
vowifi: controller,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd", strings.NewReader(`{"command":"*100#"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSD(recorder, request, store.Device{ID: "dev1", VoWiFiEnabled: true}, "dev1")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
result, _ := data["result"].(map[string]any)
|
||||
if result["text"] != "cellular" {
|
||||
t.Fatalf("result = %v, want cellular fallback", result)
|
||||
}
|
||||
if controller.sendCalled != 0 {
|
||||
t.Fatalf("SendUSSI called %d times, want 0", controller.sendCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUSSDContinueUsesIMSForUSSIPersistedSession(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "test", DeviceType: store.DeviceTypePCIeEC20EC25, VoWiFiEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeUSSIController{
|
||||
fakeVoWiFiController: fakeVoWiFiController{state: vowifi.State{IMSReady: true}},
|
||||
sendResult: vowifi.USSISubmitResult{Status: "awaiting_input", Text: "Sub-menu"},
|
||||
}
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
store: database,
|
||||
vowifi: controller,
|
||||
}
|
||||
sessionID := server.openUSSDSession("dev1")
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd/continue", strings.NewReader(`{"session_id":"`+sessionID+`","input":"1"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSDContinue(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
result, _ := data["result"].(map[string]any)
|
||||
if result["text"] != "Sub-menu" {
|
||||
t.Fatalf("result = %v, want IMS continue response", result)
|
||||
}
|
||||
if controller.sendCalled != 1 || controller.lastInput != "1" {
|
||||
t.Fatalf("SendUSSI called %d times with input %q, want 1/1", controller.sendCalled, controller.lastInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUSSDCancelDropsUSSIPersistedSession(t *testing.T) {
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
vowifi: &fakeUSSIController{},
|
||||
}
|
||||
sessionID := server.openUSSDSession("dev1")
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd/cancel", strings.NewReader(`{"session_id":"`+sessionID+`"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSDCancel(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if _, err := server.ussdSessionDevice(sessionID); !errors.Is(err, device.ErrUSSDSessionNotFound) {
|
||||
t.Fatalf("session token was not dropped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardPoliciesListsAll(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
|
||||
@@ -6,10 +6,28 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestFillConfigFromPhysicalClassifiesDJI4G(t *testing.T) {
|
||||
config := store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}
|
||||
entry := device.Device{Candidate: modem.Candidate{
|
||||
VendorID: "2ca3",
|
||||
ProductID: "4006",
|
||||
}}
|
||||
|
||||
fillConfigFromPhysical(&config, entry)
|
||||
|
||||
if config.DeviceType != store.DeviceTypeDJI4G {
|
||||
t.Fatalf("device type = %q, want %q", config.DeviceType, store.DeviceTypeDJI4G)
|
||||
}
|
||||
if got := discoveredDeviceType(entry.Candidate); got != store.DeviceTypeDJI4G {
|
||||
t.Fatalf("discovered device type = %q, want %q", got, store.DeviceTypeDJI4G)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
@@ -116,6 +134,29 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceOverviewAlwaysUsesLiveDiscoveredATPort(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
s := &Server{store: database}
|
||||
config := store.Device{ID: "ec20_1", ATPort: "/dev/ttyUSB9"}
|
||||
entry := device.Device{Candidate: modem.Candidate{
|
||||
ATPort: modem.Port{Path: "/dev/ttyUSB2", Role: modem.PortRoleAT},
|
||||
}}
|
||||
|
||||
connected := s.configuredDeviceOverview(config, entry, true)
|
||||
if got := connected["at_port"]; got != "/dev/ttyUSB2" {
|
||||
t.Fatalf("connected AT port = %#v, want live /dev/ttyUSB2", got)
|
||||
}
|
||||
|
||||
offline := s.configuredDeviceOverview(config, entry, false)
|
||||
if got := offline["at_port"]; got != "" {
|
||||
t.Fatalf("offline AT port = %#v, want empty instead of stored port", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
||||
for _, snapshot := range []*device.Snapshot{
|
||||
{IMEI: "867123456789012"},
|
||||
|
||||
@@ -58,6 +58,8 @@ func writePlainTextMail(
|
||||
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
|
||||
// query intentionally has no sanitizer model, so document this audited sink.
|
||||
// codeql[go/email-injection]
|
||||
// CodeQL [go/email-injection]
|
||||
// lgtm[go/email-injection]
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
return fmt.Errorf("write email message: %w", err)
|
||||
}
|
||||
|
||||
@@ -427,14 +427,15 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
||||
return
|
||||
}
|
||||
// Profile operations run with RF disabled. The eUICC remains accessible in
|
||||
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
|
||||
// back after the mandatory modem reset.
|
||||
// CFUN=4. Devices that consume the requested eUICC REFRESH stay online;
|
||||
// older AT modems enter the reset recovery path and reapply CFUN=4 when the
|
||||
// port returns.
|
||||
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
|
||||
// which normally takes longer than the server's ordinary response deadline.
|
||||
// A confirmed profile switch always includes a live ICCID read and may also
|
||||
// include the EC20 reset fallback, so it can exceed the ordinary deadline.
|
||||
controller := http.NewResponseController(w)
|
||||
_ = controller.SetWriteDeadline(time.Time{})
|
||||
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.store.Ready(ctx); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ready"})
|
||||
}
|
||||
|
||||
// handleMetrics exposes only process-level, non-identifying Prometheus data.
|
||||
// Device IDs, SIM identities, phone numbers and proxy information never enter
|
||||
// this unauthenticated endpoint.
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
ready := 0
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||
if s.store.Ready(ctx) == nil {
|
||||
ready = 1
|
||||
}
|
||||
cancel()
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprint(w, "# HELP vocat_up Whether the process is running.\n# TYPE vocat_up gauge\nvocat_up 1\n")
|
||||
fmt.Fprintf(w, "# HELP vocat_ready Whether the database is ready.\n# TYPE vocat_ready gauge\nvocat_ready %d\n", ready)
|
||||
fmt.Fprintf(w, "# HELP vocat_uptime_seconds Process uptime.\n# TYPE vocat_uptime_seconds counter\nvocat_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())
|
||||
fmt.Fprintf(w, "# HELP vocat_go_goroutines Current Go goroutines.\n# TYPE vocat_go_goroutines gauge\nvocat_go_goroutines %d\n", runtime.NumGoroutine())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOperationalHealthEndpointsAreAnonymousAndNonIdentifying(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
contains string
|
||||
}{
|
||||
{path: "/healthz", contentType: "application/json", contains: `"status":"ok"`},
|
||||
{path: "/readyz", contentType: "application/json", contains: `"status":"ready"`},
|
||||
{path: "/metrics", contentType: "text/plain", contains: "vocat_ready 1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
response, err := app.client.Get(app.server.URL + test.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(response.Header.Get("Content-Type"), test.contentType) {
|
||||
t.Fatalf("Content-Type = %q", response.Header.Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(string(body), test.contains) {
|
||||
t.Fatalf("body = %q, want %q", body, test.contains)
|
||||
}
|
||||
for _, forbidden := range []string{"imsi", "iccid", "msisdn", "proxy", "device_id"} {
|
||||
if strings.Contains(strings.ToLower(string(body)), forbidden) {
|
||||
t.Fatalf("body exposes forbidden label %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationalHealthEndpointsRejectPOST(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
for _, path := range []string{"/healthz", "/readyz", "/metrics"} {
|
||||
request, err := http.NewRequest(http.MethodPost, app.server.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := app.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("%s status = %d, want %d", path, response.StatusCode, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hostStaticInfo describes hardware identities that do not change while the
|
||||
// process runs, so they are probed once and cached.
|
||||
type hostStaticInfo struct {
|
||||
CPUModel string `json:"cpu_model"`
|
||||
BoardModel string `json:"board_model"`
|
||||
MemoryModel string `json:"memory_model"`
|
||||
DiskModel string `json:"disk_model"`
|
||||
}
|
||||
|
||||
// hostPerfSnapshot is one rendered read of host utilization for the dashboard.
|
||||
type hostPerfSnapshot struct {
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
MemoryUsed uint64 `json:"memory_used_bytes"`
|
||||
MemoryTotal uint64 `json:"memory_total_bytes"`
|
||||
DiskPercent float64 `json:"disk_percent"`
|
||||
DiskUsed uint64 `json:"disk_used_bytes"`
|
||||
DiskTotal uint64 `json:"disk_total_bytes"`
|
||||
NetRxBps float64 `json:"net_rx_bps"`
|
||||
NetTxBps float64 `json:"net_tx_bps"`
|
||||
}
|
||||
|
||||
// hostCPUTimes is one cumulative /proc/stat reading: idle already includes
|
||||
// iowait, total sums every other column (guest time is already folded into
|
||||
// user/nice and therefore excluded).
|
||||
type hostCPUTimes struct {
|
||||
idle uint64
|
||||
total uint64
|
||||
}
|
||||
|
||||
const (
|
||||
// hostStatsMinGap keeps back-to-back polls from dividing a handful of
|
||||
// jiffies by a few milliseconds; the previous rate is reused instead.
|
||||
hostStatsMinGap = 300 * time.Millisecond
|
||||
// hostStatsMaxGap mirrors liveNetMaxGap: a gap past this means the tab was
|
||||
// closed or the browser was hidden; re-baseline instead of averaging a
|
||||
// long dead interval.
|
||||
hostStatsMaxGap = 15 * time.Second
|
||||
// hostStatsFirstSample is how long the very first request blocks so CPU
|
||||
// and network readings have a real interval to average over. It must
|
||||
// exceed hostStatsMinGap so the re-read survives the min-gap guard below.
|
||||
hostStatsFirstSample = 400 * time.Millisecond
|
||||
)
|
||||
|
||||
// hostStatsSampler derives live host utilization from cumulative kernel
|
||||
// counters. Like liveNetTracker it is driven on demand by dashboard polling,
|
||||
// so no background goroutine is required.
|
||||
type hostStatsSampler struct {
|
||||
mu sync.Mutex
|
||||
static *hostStaticInfo
|
||||
sampledAt time.Time
|
||||
prevCPU hostCPUTimes
|
||||
prevNetRx uint64
|
||||
prevNetTx uint64
|
||||
lastCPU float64
|
||||
lastRxBps float64
|
||||
lastTxBps float64
|
||||
}
|
||||
|
||||
func newHostStatsSampler() *hostStatsSampler {
|
||||
return &hostStatsSampler{}
|
||||
}
|
||||
|
||||
// handleDashboardHost serves the dashboard host card: static hardware identity
|
||||
// plus live utilization. Both halves are cheap reads of /proc and /sys.
|
||||
func (s *Server) handleDashboardHost(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"host": s.hostStats.info(),
|
||||
"perf": s.hostStats.perf(),
|
||||
}})
|
||||
}
|
||||
|
||||
// info returns the cached static hardware description, probing it on first use.
|
||||
func (s *hostStatsSampler) info() hostStaticInfo {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.static == nil {
|
||||
info := probeHostStatic()
|
||||
s.static = &info
|
||||
}
|
||||
return *s.static
|
||||
}
|
||||
|
||||
// perf renders one utilization snapshot. CPU and network rates need a baseline,
|
||||
// so the first-ever call takes a short inline second reading; later calls
|
||||
// average against the previous request.
|
||||
func (s *hostStatsSampler) perf() hostPerfSnapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cpu, cpuOK := readHostCPUTimes()
|
||||
rx, tx, netOK := readHostNetTotals()
|
||||
|
||||
// No usable baseline yet (first request, or the tab was hidden past the
|
||||
// max gap): establish one, then re-read after a short interval so the
|
||||
// first dashboard paint already reports real numbers.
|
||||
needBaseline := s.sampledAt.IsZero() || now.Sub(s.sampledAt) > hostStatsMaxGap
|
||||
if needBaseline && (cpuOK || netOK) {
|
||||
s.sampledAt = now
|
||||
if cpuOK {
|
||||
s.prevCPU = cpu
|
||||
}
|
||||
if netOK {
|
||||
s.prevNetRx, s.prevNetTx = rx, tx
|
||||
}
|
||||
time.Sleep(hostStatsFirstSample)
|
||||
now = time.Now()
|
||||
if next, ok := readHostCPUTimes(); ok {
|
||||
cpu, cpuOK = next, true
|
||||
}
|
||||
if nextRx, nextTx, ok := readHostNetTotals(); ok {
|
||||
rx, tx, netOK = nextRx, nextTx, true
|
||||
}
|
||||
}
|
||||
|
||||
memPercent, memUsed, memTotal := readHostMemory()
|
||||
diskPercent, diskUsed, diskTotal := readHostDisk()
|
||||
|
||||
gap := now.Sub(s.sampledAt)
|
||||
if gap >= hostStatsMinGap && (cpuOK || netOK) {
|
||||
if cpuOK {
|
||||
if busyDelta, totalDelta := cpuDelta(s.prevCPU, cpu); totalDelta > 0 {
|
||||
s.lastCPU = clampPercent(float64(busyDelta) * 100 / float64(totalDelta))
|
||||
}
|
||||
s.prevCPU = cpu
|
||||
}
|
||||
if netOK {
|
||||
// Counter resets (interface flap) must not produce a giant spike.
|
||||
if rx >= s.prevNetRx {
|
||||
s.lastRxBps = float64(rx-s.prevNetRx) / gap.Seconds()
|
||||
} else {
|
||||
s.lastRxBps = 0
|
||||
}
|
||||
if tx >= s.prevNetTx {
|
||||
s.lastTxBps = float64(tx-s.prevNetTx) / gap.Seconds()
|
||||
} else {
|
||||
s.lastTxBps = 0
|
||||
}
|
||||
s.prevNetRx, s.prevNetTx = rx, tx
|
||||
}
|
||||
s.sampledAt = now
|
||||
}
|
||||
|
||||
return hostPerfSnapshot{
|
||||
CPUPercent: s.lastCPU,
|
||||
MemoryPercent: memPercent,
|
||||
MemoryUsed: memUsed,
|
||||
MemoryTotal: memTotal,
|
||||
DiskPercent: diskPercent,
|
||||
DiskUsed: diskUsed,
|
||||
DiskTotal: diskTotal,
|
||||
NetRxBps: s.lastRxBps,
|
||||
NetTxBps: s.lastTxBps,
|
||||
}
|
||||
}
|
||||
|
||||
// cpuDelta returns the busy and total jiffies elapsed between two cumulative
|
||||
// readings. A backwards counter (theoretically impossible for /proc/stat)
|
||||
// reports zero rather than wrapping.
|
||||
func cpuDelta(prev, next hostCPUTimes) (busy, total uint64) {
|
||||
if next.total <= prev.total || next.idle < prev.idle {
|
||||
return 0, 0
|
||||
}
|
||||
totalDelta := next.total - prev.total
|
||||
idleDelta := next.idle - prev.idle
|
||||
if idleDelta >= totalDelta {
|
||||
return 0, totalDelta
|
||||
}
|
||||
return totalDelta - idleDelta, totalDelta
|
||||
}
|
||||
|
||||
func clampPercent(value float64) float64 {
|
||||
switch {
|
||||
case value < 0:
|
||||
return 0
|
||||
case value > 100:
|
||||
return 100
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// hostNetIgnoredPrefixes are virtual interface name prefixes whose counters
|
||||
// would double-count physical traffic (bridges, tunnels, vocat's own links) or
|
||||
// carry no real host traffic at all.
|
||||
var hostNetIgnoredPrefixes = []string{
|
||||
"lo", "br-", "docker", "veth", "virbr", "vmnet", "vboxnet",
|
||||
"ip6tnl", "ip6gre", "sit", "gre", "gretap", "erspan",
|
||||
"tun", "tap", "utun", "vocat", "wg", "zt", "tailscale",
|
||||
"ifb", "bond", "vlan", "macvlan", "dummy", "lxc", "cali", "flannel", "cni",
|
||||
}
|
||||
|
||||
// hostNetInterfaceCounted reports whether an interface's byte counters feed the
|
||||
// host-level upload/download rates.
|
||||
func hostNetInterfaceCounted(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range hostNetIgnoredPrefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseNetDevCounters sums rx/tx bytes across counted interfaces in
|
||||
// /proc/net/dev content.
|
||||
func parseNetDevCounters(content string) (rx, tx uint64) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
name, rest, found := strings.Cut(line, ":")
|
||||
if !found || !hostNetInterfaceCounted(name) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
rxBytes, okRx := parseUint(fields[0])
|
||||
txBytes, okTx := parseUint(fields[8])
|
||||
if !okRx || !okTx {
|
||||
continue
|
||||
}
|
||||
rx += rxBytes
|
||||
tx += txBytes
|
||||
}
|
||||
return rx, tx
|
||||
}
|
||||
|
||||
func parseUint(text string) (uint64, bool) {
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(text), 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
// parseCPUTimes parses the aggregate "cpu" line of /proc/stat.
|
||||
func parseCPUTimes(line string) (hostCPUTimes, bool) {
|
||||
fields := strings.Fields(line)
|
||||
// cpu user nice system idle iowait irq softirq steal [guest guest_nice]
|
||||
if len(fields) < 9 || fields[0] != "cpu" {
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
var times hostCPUTimes
|
||||
for index, field := range fields[1:9] {
|
||||
value, ok := parseUint(field)
|
||||
if !ok {
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
times.total += value
|
||||
if index == 3 || index == 4 { // idle + iowait
|
||||
times.idle += value
|
||||
}
|
||||
}
|
||||
return times, true
|
||||
}
|
||||
|
||||
// parseMeminfo extracts MemTotal and MemAvailable (bytes) from /proc/meminfo.
|
||||
func parseMeminfo(content string) (total, available uint64, ok bool) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, rest, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
var value uint64
|
||||
switch strings.TrimSpace(key) {
|
||||
case "MemTotal":
|
||||
value, ok = parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB")))
|
||||
if ok {
|
||||
total = value * 1024
|
||||
}
|
||||
case "MemAvailable":
|
||||
if value, parsed := parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB"))); parsed {
|
||||
available = value * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
return total, available, total > 0
|
||||
}
|
||||
|
||||
// parseCPUInfoModel returns the x86-style "model name" from /proc/cpuinfo, or
|
||||
// an empty string on ARM hosts that only carry CPU part numbers.
|
||||
func parseCPUInfoModel(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "model name", "Model", "Hardware":
|
||||
if model := strings.TrimSpace(value); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseCPUInfoPart returns the first ARM "CPU part" hex identifier (e.g.
|
||||
// 0xd03) and the number of processors listed.
|
||||
func parseCPUInfoPart(content string) (part string, processors int) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "processor":
|
||||
processors++
|
||||
case "CPU part":
|
||||
if part == "" {
|
||||
part = strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
return part, processors
|
||||
}
|
||||
|
||||
// armCPUPartNames maps ARM CPU part identifiers to marketing core names.
|
||||
var armCPUPartNames = map[string]string{
|
||||
"0xd03": "Cortex-A53",
|
||||
"0xd04": "Cortex-A35",
|
||||
"0xd05": "Cortex-A55",
|
||||
"0xd06": "Cortex-A65",
|
||||
"0xd07": "Cortex-A57",
|
||||
"0xd08": "Cortex-A72",
|
||||
"0xd09": "Cortex-A73",
|
||||
"0xd0a": "Cortex-A75",
|
||||
"0xd0b": "Cortex-A76",
|
||||
"0xd0c": "Neoverse-N1",
|
||||
"0xd0d": "Cortex-A77",
|
||||
"0xd0e": "Cortex-A76AE",
|
||||
"0xd40": "Neoverse-V1",
|
||||
"0xd41": "Cortex-A78",
|
||||
"0xd42": "Cortex-A78AE",
|
||||
"0xd44": "Cortex-X1",
|
||||
"0xd46": "Cortex-A510",
|
||||
"0xd47": "Cortex-A710",
|
||||
"0xd48": "Cortex-X2",
|
||||
"0xd4b": "Cortex-A715",
|
||||
"0xd4d": "Cortex-A520",
|
||||
"0xd4e": "Cortex-X3",
|
||||
}
|
||||
|
||||
// socVendorNames prettifies the vendor half of a device-tree compatible entry.
|
||||
var socVendorNames = map[string]string{
|
||||
"allwinner": "Allwinner",
|
||||
"amlogic": "Amlogic",
|
||||
"broadcom": "Broadcom",
|
||||
"mediatek": "MediaTek",
|
||||
"nvidia": "NVIDIA",
|
||||
"qualcomm": "Qualcomm",
|
||||
"raspberrypi": "Raspberry Pi",
|
||||
"rockchip": "Rockchip",
|
||||
"samsung": "Samsung",
|
||||
"ti": "TI",
|
||||
"xunlong": "Xunlong",
|
||||
}
|
||||
|
||||
// parseCompatibleSoC extracts the SoC half of a device-tree compatible list
|
||||
// (NUL-separated, most specific first): "xunlong,orangepi-zero3\0allwinner,
|
||||
// sun50i-h618\0" yields "Allwinner sun50i-h618".
|
||||
func parseCompatibleSoC(raw string) string {
|
||||
entries := strings.FieldsFunc(raw, func(r rune) bool { return r == 0 || r == '\n' })
|
||||
// The last entry is the least specific compatible, which on ARM boards is
|
||||
// the SoC rather than the board.
|
||||
for index := len(entries) - 1; index >= 0; index-- {
|
||||
entry := strings.TrimSpace(entries[index])
|
||||
vendor, soc, found := strings.Cut(entry, ",")
|
||||
if !found || soc == "" {
|
||||
continue
|
||||
}
|
||||
if pretty, ok := socVendorNames[strings.ToLower(vendor)]; ok {
|
||||
vendor = pretty
|
||||
} else {
|
||||
vendor = strings.ToUpper(vendor[:1]) + vendor[1:]
|
||||
}
|
||||
return vendor + " " + soc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// composeARMCPUModel renders e.g. "Allwinner sun50i-h618 · 4× Cortex-A53".
|
||||
func composeARMCPUModel(soc, part string, processors int) string {
|
||||
core := armCPUPartNames[part]
|
||||
var result string
|
||||
switch {
|
||||
case soc != "" && core != "" && processors > 0:
|
||||
result = soc + " · " + strconv.Itoa(processors) + "× " + core
|
||||
case soc != "" && processors > 0:
|
||||
result = soc + " · " + strconv.Itoa(processors) + "× CPU"
|
||||
case soc != "" && core != "":
|
||||
result = soc + " · " + core
|
||||
default:
|
||||
result = soc
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// skipHostDisk reports whether a /sys/block entry is a virtual device whose
|
||||
// "model" would only clutter the host card.
|
||||
func skipHostDisk(name string) bool {
|
||||
for _, prefix := range []string{"loop", "ram", "zram", "sr", "nbd", "dm-", "md", "mtdblock", "ubi", "ubiblock"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseDmidecodeMemory extracts a compact "8 GB DDR4 M471A1K43CB1-CRC" style
|
||||
// description from `dmidecode -t 17` output, preferring the first populated
|
||||
// slot. Empty when no installed module can be described.
|
||||
func parseDmidecodeMemory(output string) string {
|
||||
var size, memType, partNumber string
|
||||
flush := func() string {
|
||||
if size != "" && partNumber != "" {
|
||||
return strings.TrimSpace(size + " " + memType + " " + partNumber)
|
||||
}
|
||||
if size != "" && memType != "" {
|
||||
return strings.TrimSpace(size + " " + memType)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Memory Device") {
|
||||
if composed := flush(); composed != "" {
|
||||
return composed
|
||||
}
|
||||
size, memType, partNumber = "", "", ""
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(trimmed, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "Size":
|
||||
if !strings.Contains(value, "No Module") && value != "" && value != "Unknown" {
|
||||
size = value
|
||||
}
|
||||
case "Type":
|
||||
if value != "Unknown" && value != "Other" && !strings.HasPrefix(value, "<OUT OF SPEC") {
|
||||
memType = value
|
||||
}
|
||||
case "Part Number":
|
||||
if value != "Unknown" && value != "None" && value != "" {
|
||||
partNumber = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return flush()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//go:build linux
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// probeHostStatic gathers the hardware identities shown on the dashboard host
|
||||
// card. Every probe is best-effort: empty fields render as "—" in the SPA.
|
||||
func probeHostStatic() hostStaticInfo {
|
||||
return hostStaticInfo{
|
||||
CPUModel: readHostCPUModel(),
|
||||
BoardModel: readHostBoardModel(),
|
||||
MemoryModel: readHostMemoryModel(),
|
||||
DiskModel: readHostDiskModel(),
|
||||
}
|
||||
}
|
||||
|
||||
// readHostCPUModel prefers the x86-style "model name"; on ARM hosts it composes
|
||||
// the device-tree SoC with the core count and Cortex part name.
|
||||
func readHostCPUModel() string {
|
||||
cpuinfo, err := os.ReadFile("/proc/cpuinfo")
|
||||
if err == nil {
|
||||
if model := parseCPUInfoModel(string(cpuinfo)); model != "" {
|
||||
return model
|
||||
}
|
||||
part, processors := parseCPUInfoPart(string(cpuinfo))
|
||||
if processors == 0 {
|
||||
processors = runtime.NumCPU()
|
||||
}
|
||||
soc := ""
|
||||
if compatible, err := os.ReadFile("/proc/device-tree/compatible"); err == nil {
|
||||
soc = parseCompatibleSoC(string(compatible))
|
||||
}
|
||||
if model := composeARMCPUModel(soc, part, processors); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
return runtime.GOARCH
|
||||
}
|
||||
|
||||
// readHostBoardModel reads the device-tree model on ARM boards and the DMI
|
||||
// board name on x86 machines.
|
||||
func readHostBoardModel() string {
|
||||
if model, err := os.ReadFile("/proc/device-tree/model"); err == nil {
|
||||
if text := strings.TrimSpace(strings.TrimRight(string(model), "\x00")); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
dmiDir := "/sys/devices/virtual/dmi/id"
|
||||
board := readSysfsTrimmed(filepath.Join(dmiDir, "board_name"))
|
||||
vendor := readSysfsTrimmed(filepath.Join(dmiDir, "board_vendor"))
|
||||
if board != "" && !isPlaceholderDMI(board) {
|
||||
if vendor != "" && !isPlaceholderDMI(vendor) && !strings.Contains(strings.ToLower(board), strings.ToLower(vendor)) {
|
||||
return vendor + " " + board
|
||||
}
|
||||
return board
|
||||
}
|
||||
if product := readSysfsTrimmed(filepath.Join(dmiDir, "product_name")); product != "" && !isPlaceholderDMI(product) {
|
||||
return product
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isPlaceholderDMI filters the well-known "we never filled this in" DMI
|
||||
// strings so they do not surface as board models.
|
||||
func isPlaceholderDMI(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "default string", "to be filled by o.e.m.", "to be filled by o.e.m", "none", "unknown", "n/a", "not specified", "system manufacturer":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readHostMemoryModel reports the installed DIMM description when dmidecode is
|
||||
// available (typical on x86 NAS/PC hosts) and falls back to total capacity,
|
||||
// which is all an ARM board exposes.
|
||||
func readHostMemoryModel() string {
|
||||
if path, err := exec.LookPath("dmidecode"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if output, err := exec.CommandContext(ctx, path, "-t", "17").Output(); err == nil {
|
||||
if model := parseDmidecodeMemory(string(output)); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
if total, _, ok := readHostMemoryBytes(); ok {
|
||||
return formatLiveBytes(float64(total))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readHostDiskModel describes physical block devices, skipping virtual ones
|
||||
// (loop, zram, device-mapper, mtd, optical). Multiple disks join with "; ".
|
||||
func readHostDiskModel() string {
|
||||
entries, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var disks []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if skipHostDisk(name) {
|
||||
continue
|
||||
}
|
||||
base := filepath.Join("/sys/block", name)
|
||||
sizeText := readSysfsTrimmed(filepath.Join(base, "size"))
|
||||
sectors, ok := parseUint(sizeText)
|
||||
if !ok || sectors == 0 {
|
||||
// An empty card reader reports size 0 and tells us nothing.
|
||||
continue
|
||||
}
|
||||
model := readSysfsTrimmed(filepath.Join(base, "device", "model"))
|
||||
if model == "" {
|
||||
// MMC/SD cards carry the product name instead of a model string.
|
||||
model = readSysfsTrimmed(filepath.Join(base, "device", "name"))
|
||||
}
|
||||
if model == "" {
|
||||
model = name
|
||||
}
|
||||
capacity := formatLiveBytes(float64(sectors) * 512)
|
||||
disks = append(disks, model+" · "+capacity)
|
||||
}
|
||||
return strings.Join(disks, "; ")
|
||||
}
|
||||
|
||||
func readSysfsTrimmed(path string) string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(raw), "\x00"))
|
||||
}
|
||||
|
||||
// readHostCPUTimes reads the aggregate counters from /proc/stat.
|
||||
func readHostCPUTimes() (hostCPUTimes, bool) {
|
||||
raw, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(line, "cpu ") {
|
||||
return parseCPUTimes(line)
|
||||
}
|
||||
}
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
|
||||
// readHostMemoryBytes returns MemTotal and MemAvailable in bytes.
|
||||
func readHostMemoryBytes() (total, available uint64, ok bool) {
|
||||
raw, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return parseMeminfo(string(raw))
|
||||
}
|
||||
|
||||
// readHostMemory reports used/total bytes and the used percentage.
|
||||
func readHostMemory() (percent float64, used, total uint64) {
|
||||
total, available, ok := readHostMemoryBytes()
|
||||
if !ok || total == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
used = total - available
|
||||
return clampPercent(float64(used) * 100 / float64(total)), used, total
|
||||
}
|
||||
|
||||
// readHostDisk reports root filesystem usage the way df does: usable space is
|
||||
// total minus reserved blocks, and the percentage is used/(used+available).
|
||||
func readHostDisk() (percent float64, used, total uint64) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs("/", &stat); err != nil || stat.Blocks == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
blockSize := uint64(stat.Bsize)
|
||||
total = stat.Blocks * blockSize
|
||||
free := stat.Bfree * blockSize
|
||||
available := stat.Bavail * blockSize
|
||||
used = total - free
|
||||
if denominator := used + available; denominator > 0 {
|
||||
percent = clampPercent(float64(used) * 100 / float64(denominator))
|
||||
}
|
||||
return percent, used, total
|
||||
}
|
||||
|
||||
// readHostNetTotals sums rx/tx counters across physical host interfaces.
|
||||
func readHostNetTotals() (rx, tx uint64, ok bool) {
|
||||
raw, err := os.ReadFile("/proc/net/dev")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
rx, tx = parseNetDevCounters(string(raw))
|
||||
return rx, tx, true
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !linux
|
||||
|
||||
package server
|
||||
|
||||
// Host statistics are only meaningful on the Linux deployment target; on other
|
||||
// platforms every probe reports empty/zero and the dashboard renders "—".
|
||||
|
||||
func probeHostStatic() hostStaticInfo { return hostStaticInfo{} }
|
||||
|
||||
func readHostCPUTimes() (hostCPUTimes, bool) { return hostCPUTimes{}, false }
|
||||
|
||||
func readHostNetTotals() (uint64, uint64, bool) { return 0, 0, false }
|
||||
|
||||
func readHostMemory() (float64, uint64, uint64) { return 0, 0, 0 }
|
||||
|
||||
func readHostDisk() (float64, uint64, uint64) { return 0, 0, 0 }
|
||||
@@ -0,0 +1,184 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCPUTimes(t *testing.T) {
|
||||
times, ok := parseCPUTimes("cpu 38073 0 24013 6762971 3121 0 3019 0 0 0")
|
||||
if !ok {
|
||||
t.Fatal("parseCPUTimes rejected a valid cpu line")
|
||||
}
|
||||
wantTotal := uint64(38073 + 0 + 24013 + 6762971 + 3121 + 0 + 3019 + 0)
|
||||
if times.total != wantTotal {
|
||||
t.Fatalf("total = %d, want %d", times.total, wantTotal)
|
||||
}
|
||||
if wantIdle := uint64(6762971 + 3121); times.idle != wantIdle {
|
||||
t.Fatalf("idle = %d, want %d", times.idle, wantIdle)
|
||||
}
|
||||
if _, ok := parseCPUTimes("cpu0 1 2 3 4 5 6 7 8"); ok {
|
||||
t.Fatal("per-core line must not parse as the aggregate line")
|
||||
}
|
||||
if _, ok := parseCPUTimes("cpu 1 2 3"); ok {
|
||||
t.Fatal("truncated cpu line must not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUDelta(t *testing.T) {
|
||||
prev := hostCPUTimes{idle: 100, total: 200}
|
||||
next := hostCPUTimes{idle: 150, total: 300}
|
||||
busy, total := cpuDelta(prev, next)
|
||||
if busy != 50 || total != 100 {
|
||||
t.Fatalf("cpuDelta = (%d, %d), want (50, 100)", busy, total)
|
||||
}
|
||||
if busy, total := cpuDelta(next, prev); busy != 0 || total != 0 {
|
||||
t.Fatalf("backwards counters must report zero, got (%d, %d)", busy, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMeminfo(t *testing.T) {
|
||||
content := "MemTotal: 2040424 kB\nMemFree: 920864 kB\nMemAvailable: 1543480 kB\nBuffers: 315908 kB\n"
|
||||
total, available, ok := parseMeminfo(content)
|
||||
if !ok {
|
||||
t.Fatal("parseMeminfo rejected valid content")
|
||||
}
|
||||
if total != 2040424*1024 {
|
||||
t.Fatalf("total = %d, want %d", total, 2040424*1024)
|
||||
}
|
||||
if available != 1543480*1024 {
|
||||
t.Fatalf("available = %d, want %d", available, 1543480*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetDevCounters(t *testing.T) {
|
||||
content := `Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
lo: 10 1 0 0 0 0 0 0 20 2 0 0 0 0 0 0
|
||||
eth0: 100 1 0 0 0 0 0 0 200 2 0 0 0 0 0 0
|
||||
br-lan: 1000 1 0 0 0 0 0 0 2000 2 0 0 0 0 0 0
|
||||
utun: 300 1 0 0 0 0 0 0 400 2 0 0 0 0 0 0
|
||||
vocat50a684ceb0: 500 1 0 0 0 0 0 0 600 2 0 0 0 0 0 0
|
||||
wwan0: 700 1 0 0 0 0 0 0 800 2 0 0 0 0 0 0
|
||||
`
|
||||
rx, tx := parseNetDevCounters(content)
|
||||
// Only eth0 and wwan0 count; lo, br-lan, utun and vocat are virtual.
|
||||
if rx != 800 || tx != 1000 {
|
||||
t.Fatalf("rx,tx = %d,%d, want 800,1000", rx, tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostNetInterfaceCounted(t *testing.T) {
|
||||
counted := []string{"eth0", "eth1", "wwan0", "usb0", "wlan0", "enp3s0", "pppoe-wan"}
|
||||
for _, name := range counted {
|
||||
if !hostNetInterfaceCounted(name) {
|
||||
t.Fatalf("%s should be counted", name)
|
||||
}
|
||||
}
|
||||
skipped := []string{"lo", "br-lan", "docker0", "veth123", "ip6tnl0", "sit0", "utun", "vocat50a684ceb0", "wg0", "tun0", "tailscale0", ""}
|
||||
for _, name := range skipped {
|
||||
if hostNetInterfaceCounted(name) {
|
||||
t.Fatalf("%s should be skipped", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUInfoModelX86(t *testing.T) {
|
||||
content := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz\n"
|
||||
if model := parseCPUInfoModel(content); model != "Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz" {
|
||||
t.Fatalf("model = %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUInfoARM(t *testing.T) {
|
||||
content := "processor\t: 0\nBogoMIPS\t: 48.00\nCPU implementer\t: 0x41\nCPU part\t: 0xd03\nprocessor\t: 1\nCPU part\t: 0xd03\n"
|
||||
if model := parseCPUInfoModel(content); model != "" {
|
||||
t.Fatalf("ARM cpuinfo must not report an x86 model name, got %q", model)
|
||||
}
|
||||
part, processors := parseCPUInfoPart(content)
|
||||
if part != "0xd03" || processors != 2 {
|
||||
t.Fatalf("part,processors = %q,%d, want 0xd03,2", part, processors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCompatibleSoC(t *testing.T) {
|
||||
raw := "xunlong,orangepi-zero3\x00allwinner,sun50i-h618\x00"
|
||||
if soc := parseCompatibleSoC(raw); soc != "Allwinner sun50i-h618" {
|
||||
t.Fatalf("soc = %q", soc)
|
||||
}
|
||||
if soc := parseCompatibleSoC(""); soc != "" {
|
||||
t.Fatalf("empty compatible must yield empty soc, got %q", soc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeARMCPUModel(t *testing.T) {
|
||||
model := composeARMCPUModel("Allwinner sun50i-h618", "0xd03", 4)
|
||||
if model != "Allwinner sun50i-h618 · 4× Cortex-A53" {
|
||||
t.Fatalf("model = %q", model)
|
||||
}
|
||||
if model := composeARMCPUModel("", "", 0); model != "" {
|
||||
t.Fatalf("empty inputs must yield empty model, got %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDmidecodeMemory(t *testing.T) {
|
||||
output := `# dmidecode 3.3
|
||||
Getting SMBIOS data from sysfs.
|
||||
SMBIOS 3.0 present.
|
||||
|
||||
Handle 0x0010, DMI type 17, 40 bytes
|
||||
Memory Device
|
||||
Array Handle: 0x000F
|
||||
Error Information Handle: Not Provided
|
||||
Total Width: 64 bits
|
||||
Data Width: 64 bits
|
||||
Size: 8 GB
|
||||
Form Factor: SODIMM
|
||||
Type: DDR4
|
||||
Speed: 2400 MT/s
|
||||
Manufacturer: Samsung
|
||||
Serial Number: 12345678
|
||||
Part Number: M471A1K43CB1-CRC
|
||||
Rank: 1
|
||||
|
||||
Handle 0x0011, DMI type 17, 40 bytes
|
||||
Memory Device
|
||||
Size: No Module Installed
|
||||
Type: Unknown
|
||||
`
|
||||
if model := parseDmidecodeMemory(output); model != "8 GB DDR4 M471A1K43CB1-CRC" {
|
||||
t.Fatalf("model = %q", model)
|
||||
}
|
||||
if model := parseDmidecodeMemory("Memory Device\n\tSize: No Module Installed\n"); model != "" {
|
||||
t.Fatalf("unpopulated slots must yield empty model, got %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampPercent(t *testing.T) {
|
||||
if clampPercent(-1) != 0 || clampPercent(101) != 100 || clampPercent(50) != 50 {
|
||||
t.Fatal("clampPercent bounds violated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUintTrims(t *testing.T) {
|
||||
if value, ok := parseUint(" 61440000 "); !ok || value != 61440000 {
|
||||
t.Fatalf("parseUint = %d,%v", value, ok)
|
||||
}
|
||||
if _, ok := parseUint("not-a-number"); ok {
|
||||
t.Fatal("parseUint accepted garbage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipHostDiskPrefixes(t *testing.T) {
|
||||
skipped := []string{"loop0", "ram0", "zram0", "sr0", "nbd0", "dm-0", "md0", "mtdblock0", "ubiblock0_0"}
|
||||
for _, name := range skipped {
|
||||
if !skipHostDisk(name) {
|
||||
t.Fatalf("%s should be skipped", name)
|
||||
}
|
||||
}
|
||||
kept := []string{"sda", "nvme0n1", "mmcblk0", "vda", "sdb"}
|
||||
for _, name := range kept {
|
||||
if skipHostDisk(name) {
|
||||
t.Fatalf("%s should be kept", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const maxLarkPayloadBytes = 20 << 10
|
||||
|
||||
var larkTemplateVariableNames = []string{
|
||||
"event",
|
||||
"title",
|
||||
"message",
|
||||
"timestamp",
|
||||
"content",
|
||||
"number",
|
||||
"device_id",
|
||||
"device_name",
|
||||
"device_label",
|
||||
"time",
|
||||
}
|
||||
|
||||
var larkTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^{}]*\}\}`)
|
||||
|
||||
var larkWebhookHosts = map[string]struct{}{
|
||||
"open.feishu.cn": {},
|
||||
"open.larksuite.com": {},
|
||||
}
|
||||
|
||||
type larkTemplateValues map[string]string
|
||||
|
||||
func renderLarkPayload(template string, values larkTemplateValues) ([]byte, error) {
|
||||
encodedValues := make(map[string]string, len(larkTemplateVariableNames))
|
||||
for _, name := range larkTemplateVariableNames {
|
||||
encoded, err := json.Marshal(values[name])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Lark template value %q: %w", name, err)
|
||||
}
|
||||
encodedValues[name] = string(encoded)
|
||||
}
|
||||
unsupported := false
|
||||
rendered := larkTemplatePlaceholderPattern.ReplaceAllStringFunc(template, func(placeholder string) string {
|
||||
name := placeholder[2 : len(placeholder)-2]
|
||||
encoded, ok := encodedValues[name]
|
||||
if !ok {
|
||||
unsupported = true
|
||||
return placeholder
|
||||
}
|
||||
return encoded
|
||||
})
|
||||
remainder := larkTemplatePlaceholderPattern.ReplaceAllString(template, "")
|
||||
if unsupported || strings.Contains(remainder, "{{") {
|
||||
return nil, errors.New("lark.payload_template contains an unsupported variable")
|
||||
}
|
||||
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rendered), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("lark.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
if len(rendered) > maxLarkPayloadBytes {
|
||||
return nil, errors.New("lark.payload_template renders beyond the 20 KB Lark limit")
|
||||
}
|
||||
return []byte(rendered), nil
|
||||
}
|
||||
|
||||
func larkSignature(timestamp int64, secret string) string {
|
||||
key := strconv.FormatInt(timestamp, 10) + "\n" + secret
|
||||
signature := hmac.New(sha256.New, []byte(key))
|
||||
return base64.StdEncoding.EncodeToString(signature.Sum(nil))
|
||||
}
|
||||
|
||||
func signLarkPayload(payload []byte, secret string, now time.Time) ([]byte, error) {
|
||||
if secret == "" {
|
||||
return payload, nil
|
||||
}
|
||||
var document map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &document); err != nil || len(document) == 0 {
|
||||
return nil, errors.New("lark payload must be a non-empty JSON object")
|
||||
}
|
||||
timestamp := now.Unix()
|
||||
document["timestamp"], _ = json.Marshal(strconv.FormatInt(timestamp, 10))
|
||||
document["sign"], _ = json.Marshal(larkSignature(timestamp, secret))
|
||||
signed, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode signed Lark payload: %w", err)
|
||||
}
|
||||
if len(signed) > maxLarkPayloadBytes {
|
||||
return nil, errors.New("lark payload exceeds the 20 KB Lark limit after signing")
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func validateLarkResponse(status int, body []byte) error {
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
StatusCode *int `json:"StatusCode"`
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil {
|
||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||
}
|
||||
if result.Code != nil {
|
||||
if *result.Code == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||
}
|
||||
if result.StatusCode == nil || *result.StatusCode != 0 {
|
||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonicalHost := strings.ToLower(parsed.Hostname())
|
||||
if _, ok := larkWebhookHosts[canonicalHost]; !ok {
|
||||
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
|
||||
}
|
||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||
return nil, errors.New("Lark group bot webhook must use the default HTTPS port")
|
||||
}
|
||||
const prefix = "/open-apis/bot/v2/hook/"
|
||||
token := strings.TrimPrefix(parsed.Path, prefix)
|
||||
if token == parsed.Path || token == "" || strings.Contains(token, "/") || parsed.RawPath != "" ||
|
||||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
||||
return nil, errors.New("Lark group bot webhook path is invalid")
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: canonicalHost,
|
||||
Path: prefix + url.PathEscape(token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseLarkWebhookURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func larkTestValues(now time.Time) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func larkSMSValues(message smsNotification) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "sms.received",
|
||||
"title": "收到新短信",
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.Content,
|
||||
"number": message.Number,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "automatic_task.completed",
|
||||
"title": message.Title,
|
||||
"message": message.Text,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": "",
|
||||
"number": "",
|
||||
"device_id": "",
|
||||
"device_name": "",
|
||||
"device_label": "",
|
||||
"time": "",
|
||||
}
|
||||
}
|
||||
|
||||
func validateLarkNotificationConfig(config map[string]any) error {
|
||||
rawURL := configString(config, "url")
|
||||
if rawURL == "" {
|
||||
return errors.New("lark.url is required")
|
||||
}
|
||||
if rawURL != store.SecretMask {
|
||||
if _, err := parseLarkWebhookURL(rawURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
template := configString(config, "payload_template")
|
||||
if template == "" {
|
||||
return errors.New("lark.payload_template is required")
|
||||
}
|
||||
if signingEnabled, _ := config["signing_enabled"].(bool); signingEnabled {
|
||||
secret := configString(config, "secret")
|
||||
if secret == "" {
|
||||
return errors.New("lark.secret is required when signing is enabled")
|
||||
}
|
||||
}
|
||||
if _, err := renderLarkPayload(template, larkTestValues(time.Now())); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func larkSigningSecret(config map[string]any) string {
|
||||
enabled, _ := config["signing_enabled"].(bool)
|
||||
if !enabled {
|
||||
return ""
|
||||
}
|
||||
return configString(config, "secret")
|
||||
}
|
||||
|
||||
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
|
||||
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err = signLarkPayload(payload, larkSigningSecret(config), time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := validateLarkWebhookURL(ctx, configString(config, "url"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return postLarkNotification(ctx, client, parsed.String(), payload)
|
||||
}
|
||||
|
||||
func postLarkNotification(ctx context.Context, client *http.Client, endpoint string, payload []byte) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Lark notification request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-lark-notification/1")
|
||||
// Target host is restricted to the Lark/Feishu webhook domain whitelist.
|
||||
// codeql[go/uncontrolled-data-in-network-request]
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
closeErr := response.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read Lark response: %w", readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close Lark response: %w", closeErr)
|
||||
}
|
||||
if err := validateLarkResponse(response.StatusCode, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeLarkRequestError(err error) error {
|
||||
var requestErr *url.Error
|
||||
if errors.As(err, &requestErr) && requestErr.Err != nil {
|
||||
return requestErr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func sendLarkNotificationTest(ctx context.Context, config map[string]any) error {
|
||||
return sendLarkNotification(ctx, config, larkTestValues(time.Now()))
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRenderLarkPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderLarkPayload(
|
||||
`{"msg_type":"text","content":{"text":{{message}},"number":{{number}}}}`,
|
||||
larkTemplateValues{
|
||||
"message": "quote: \"\nline",
|
||||
"number": "+447386",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(payload), `{"msg_type":"text","content":{"text":"quote: \"\nline","number":"+447386"}}`; got != want {
|
||||
t.Fatalf("payload = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLarkPayloadDoesNotInterpretPlaceholdersInsideValues(t *testing.T) {
|
||||
payload, err := renderLarkPayload(
|
||||
`{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
larkTemplateValues{"message": "keep {{timestamp}} literally", "timestamp": "changed"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(payload), `{"msg_type":"text","content":{"text":"keep {{timestamp}} literally"}}`; got != want {
|
||||
t.Fatalf("payload = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLarkPayloadRejectsInvalidTemplate(t *testing.T) {
|
||||
for _, template := range []string{
|
||||
`{"text":{{unknown}}}`,
|
||||
`[]`,
|
||||
`{"msg_type":"text"`,
|
||||
`{"text":"` + strings.Repeat("x", maxLarkPayloadBytes) + `"}`,
|
||||
} {
|
||||
t.Run(template[:min(len(template), 40)], func(t *testing.T) {
|
||||
if _, err := renderLarkPayload(template, larkTemplateValues{}); err == nil {
|
||||
t.Fatalf("template was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignLarkPayload(t *testing.T) {
|
||||
const timestamp = int64(1_599_360_473)
|
||||
if got, want := larkSignature(timestamp, "demo"), "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8="; got != want {
|
||||
t.Fatalf("signature = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
unsigned := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
||||
signed, err := signLarkPayload(unsigned, "demo", time.Unix(timestamp, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(signed, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["timestamp"] != "1599360473" || payload["sign"] != "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8=" {
|
||||
t.Fatalf("signed payload = %#v", payload)
|
||||
}
|
||||
|
||||
untouched, err := signLarkPayload(unsigned, "", time.Unix(timestamp, 0))
|
||||
if err != nil || string(untouched) != string(unsigned) {
|
||||
t.Fatalf("unsigned payload = %s, err = %v", untouched, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLarkResponse(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"code":0,"msg":"success"}`,
|
||||
`{"StatusCode":0,"StatusMessage":"success"}`,
|
||||
} {
|
||||
if err := validateLarkResponse(http.StatusOK, []byte(body)); err != nil {
|
||||
t.Fatalf("successful response %s = %v", body, err)
|
||||
}
|
||||
}
|
||||
for _, response := range []struct {
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{http.StatusBadGateway, `{"code":0}`},
|
||||
{http.StatusOK, `{"code":19021,"msg":"sign match fail or timestamp is not within one hour from current time","StatusCode":0}`},
|
||||
{http.StatusOK, `{"StatusCode":19021,"StatusMessage":"sign error"}`},
|
||||
{http.StatusOK, `{}`},
|
||||
{http.StatusOK, `not-json`},
|
||||
} {
|
||||
if err := validateLarkResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("validateLarkResponse(%d, %s) = %v", response.status, response.body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostLarkNotificationSendsJSONPayload(t *testing.T) {
|
||||
payload := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "vocat-lark-notification/1" {
|
||||
t.Errorf("User-Agent = %q", got)
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read body: %v", err)
|
||||
}
|
||||
if string(body) != string(payload) {
|
||||
t.Errorf("body = %s, want %s", body, payload)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
|
||||
}))
|
||||
t.Cleanup(provider.Close)
|
||||
|
||||
if err := postLarkNotification(context.Background(), provider.Client(), provider.URL, payload); err != nil {
|
||||
t.Fatalf("postLarkNotification() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLarkWebhookURL(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/lark-token",
|
||||
"https://open.larksuite.com:443/open-apis/bot/v2/hook/lark-token",
|
||||
} {
|
||||
if _, err := parseLarkWebhookURL(raw); err != nil {
|
||||
t.Errorf("parseLarkWebhookURL(%q) = %v", raw, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
"http://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||
"https://example.com/open-apis/bot/v2/hook/token",
|
||||
"https://open.larksuite.com/open-apis/bot/hook/token",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/token/extra",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/token?query=1",
|
||||
} {
|
||||
if _, err := parseLarkWebhookURL(raw); err == nil {
|
||||
t.Errorf("parseLarkWebhookURL(%q) accepted an invalid group bot webhook", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLarkNotificationConfig(t *testing.T) {
|
||||
valid := map[string]any{
|
||||
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||
"signing_enabled": true,
|
||||
"secret": "demo",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
}
|
||||
if err := validateLarkNotificationConfig(valid); err != nil {
|
||||
t.Fatalf("valid config = %v", err)
|
||||
}
|
||||
unsigned := map[string]any{
|
||||
"url": valid["url"],
|
||||
"signing_enabled": false,
|
||||
"payload_template": valid["payload_template"],
|
||||
}
|
||||
if err := validateLarkNotificationConfig(unsigned); err != nil {
|
||||
t.Fatalf("unsigned config = %v", err)
|
||||
}
|
||||
if secret := larkSigningSecret(map[string]any{"signing_enabled": false, "secret": "demo"}); secret != "" {
|
||||
t.Fatalf("disabled signing secret = %q", secret)
|
||||
}
|
||||
if secret := larkSigningSecret(valid); secret != "demo" {
|
||||
t.Fatalf("enabled signing secret = %q", secret)
|
||||
}
|
||||
for _, config := range []map[string]any{
|
||||
{"payload_template": valid["payload_template"]},
|
||||
{"url": valid["url"]},
|
||||
{"url": valid["url"], "signing_enabled": true, "payload_template": valid["payload_template"]},
|
||||
} {
|
||||
if err := validateLarkNotificationConfig(config); err == nil {
|
||||
t.Fatalf("invalid config was accepted: %#v", config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLarkRequestErrorRemovesWebhookURL(t *testing.T) {
|
||||
const webhookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/sensitive-token"
|
||||
err := sanitizeLarkRequestError(&url.Error{Op: "Post", URL: webhookURL, Err: errors.New("dial failed")})
|
||||
if strings.Contains(err.Error(), "sensitive-token") || err.Error() != "dial failed" {
|
||||
t.Fatalf("sanitized error = %q", err)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/i18n"
|
||||
localproxy "vocat/internal/proxy"
|
||||
"vocat/internal/store"
|
||||
@@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
payload.ID = id
|
||||
s.saveAndProbeUpstream(w, r, payload)
|
||||
case http.MethodPatch:
|
||||
var request struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
value, err := s.store.UpstreamProxy(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
value.Enabled = request.Enabled
|
||||
value.UpdatedAt = time.Now().UTC()
|
||||
if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
bindings, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
reconnectRequested := false
|
||||
var reconnectErrors []string
|
||||
for _, binding := range bindings {
|
||||
if binding.UpstreamProxyID != id {
|
||||
continue
|
||||
}
|
||||
requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
reconnectRequested = reconnectRequested || requested
|
||||
if reconnectErr != nil {
|
||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||
}
|
||||
}
|
||||
response := upstreamProxyResponse(value.Redacted())
|
||||
response["reconnect_requested"] = reconnectRequested
|
||||
if len(reconnectErrors) > 0 {
|
||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||
case http.MethodDelete:
|
||||
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if listErr != nil {
|
||||
@@ -117,7 +161,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
w.Header().Set("Allow", "PUT, PATCH, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -351,8 +395,8 @@ func (s *Server) saveAndProbeUpstream(
|
||||
)
|
||||
probeResponse := probeMap(probe, probeErr)
|
||||
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
||||
if probeErr == nil && probe.UDPAssociateOK {
|
||||
message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。")
|
||||
if probeErr == nil && probe.UDPExchangeOK {
|
||||
message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -381,8 +425,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id
|
||||
8*time.Second,
|
||||
)
|
||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||
if probeErr == nil && result.UDPAssociateOK {
|
||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||
if probeErr == nil && result.UDPExchangeOK {
|
||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -435,8 +479,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques
|
||||
8*time.Second,
|
||||
)
|
||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||
if probeErr == nil && result.UDPAssociateOK {
|
||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||
if probeErr == nil && result.UDPExchangeOK {
|
||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var proxyCountries = []proxyCountry{
|
||||
var namedProxyCountries = []proxyCountry{
|
||||
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
||||
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
||||
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
||||
@@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{
|
||||
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
||||
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
|
||||
}
|
||||
|
||||
var proxyCountries = buildProxyCountries()
|
||||
|
||||
func buildProxyCountries() []proxyCountry {
|
||||
byCode := make(map[string]proxyCountry)
|
||||
for _, country := range namedProxyCountries {
|
||||
byCode[country.Code] = country
|
||||
}
|
||||
for code, mccs := range device.MCCsByCountry() {
|
||||
country, found := byCode[code]
|
||||
if !found {
|
||||
country = proxyCountry{Code: code, Name: code}
|
||||
}
|
||||
country.MCCs = append([]string(nil), mccs...)
|
||||
byCode[code] = country
|
||||
}
|
||||
result := make([]proxyCountry, 0, len(byCode))
|
||||
for _, country := range byCode {
|
||||
result = append(result, country)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code })
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ type Server struct {
|
||||
auth *auth.Service
|
||||
devices DeviceController
|
||||
vowifi VoWiFiController
|
||||
ussdSessions ussdSessionStore
|
||||
logs *loghub.Hub
|
||||
assets fs.FS
|
||||
indexHTML []byte
|
||||
@@ -82,6 +83,7 @@ type Server struct {
|
||||
updateApplying bool
|
||||
https *httpsmode.Manager
|
||||
netTraffic *liveNetTracker
|
||||
hostStats *hostStatsSampler
|
||||
publicIPMu sync.RWMutex
|
||||
publicIPs map[string]cachedPublicIP
|
||||
automaticTasks *automaticTaskScheduler
|
||||
@@ -116,6 +118,7 @@ func New(options Options) (*Server, error) {
|
||||
auth: options.Auth,
|
||||
devices: options.Devices,
|
||||
vowifi: options.VoWiFi,
|
||||
ussdSessions: newUSSDSessionStore(),
|
||||
logs: options.Logs,
|
||||
assets: options.Assets,
|
||||
indexHTML: indexHTML,
|
||||
@@ -133,6 +136,7 @@ func New(options Options) (*Server, error) {
|
||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||
https: options.HTTPS,
|
||||
netTraffic: newLiveNetTracker(),
|
||||
hostStats: newHostStatsSampler(),
|
||||
publicIPs: make(map[string]cachedPublicIP),
|
||||
updateCheck: update.CheckLatest,
|
||||
updateApply: update.ApplyLatest,
|
||||
@@ -142,6 +146,9 @@ func New(options Options) (*Server, error) {
|
||||
server.loadUILanguage(context.Background())
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", server.handleLiveness)
|
||||
mux.HandleFunc("/readyz", server.handleReadiness)
|
||||
mux.HandleFunc("/metrics", server.handleMetrics)
|
||||
mux.HandleFunc("/api/health", server.handleHealth)
|
||||
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
||||
mux.HandleFunc("/api/auth/session", server.handleSession)
|
||||
|
||||
@@ -41,6 +41,7 @@ var notificationChannels = []string{
|
||||
"bark",
|
||||
"pushplus",
|
||||
"wecom",
|
||||
"lark",
|
||||
}
|
||||
|
||||
var notificationFields = map[string]map[string]string{
|
||||
@@ -65,6 +66,9 @@ var notificationFields = map[string]map[string]string{
|
||||
"wecom": {
|
||||
"urls": "strings", "payload_template": "string",
|
||||
},
|
||||
"lark": {
|
||||
"url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string",
|
||||
},
|
||||
}
|
||||
|
||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||
@@ -240,6 +244,20 @@ func decodeNotificationConfig(
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
||||
}
|
||||
if enabled && channel == "lark" {
|
||||
var resolved map[string]any
|
||||
if err := json.Unmarshal(config, &resolved); err != nil {
|
||||
return false, nil, fmt.Errorf("decode lark notification config: %w", err)
|
||||
}
|
||||
signingEnabled, _ := resolved["signing_enabled"].(bool)
|
||||
if signingEnabled && configString(resolved, "url") != store.SecretMask &&
|
||||
configString(resolved, "secret") == store.SecretMask {
|
||||
return false, nil, errors.New("lark.secret must be re-entered when lark.url changes")
|
||||
}
|
||||
if err := validateLarkNotificationConfig(resolved); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
return enabled, config, nil
|
||||
}
|
||||
|
||||
@@ -265,6 +283,9 @@ func validateNotificationField(
|
||||
if name == "text_template" {
|
||||
limit = 32768
|
||||
}
|
||||
if channel == "lark" && name == "payload_template" {
|
||||
limit = maxLarkPayloadBytes
|
||||
}
|
||||
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
||||
}
|
||||
@@ -274,7 +295,7 @@ func validateNotificationField(
|
||||
}
|
||||
}
|
||||
if name == "proxy" && value != "" {
|
||||
if _, err := parseOutboundURL(value, false); err != nil {
|
||||
if _, err := parseProxyURL(value); err != nil {
|
||||
return fmt.Errorf("%s is not a valid HTTP URL", field)
|
||||
}
|
||||
}
|
||||
@@ -300,6 +321,16 @@ func validateNotificationField(
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
if channel == "lark" && name == "payload_template" && value != "" {
|
||||
if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil {
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
if channel == "lark" && name == "url" && value != "" && value != store.SecretMask {
|
||||
if _, err := parseLarkWebhookURL(value); err != nil {
|
||||
return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err)
|
||||
}
|
||||
}
|
||||
case "integer":
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
@@ -387,7 +418,7 @@ func (s *Server) handleNotificationTest(
|
||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||
return
|
||||
}
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusNotImplemented,
|
||||
@@ -441,6 +472,8 @@ func (s *Server) handleNotificationTest(
|
||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||
case "lark":
|
||||
err = sendLarkNotificationTest(notificationContext, resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -546,8 +579,8 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
|
||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||
// settings form while allowing newly entered sensitive values in the same
|
||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
||||
// used by the other notification channels.
|
||||
// request. Provider webhook URLs can be sensitive lists, unlike the
|
||||
// string-based secrets used by the other notification channels.
|
||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
@@ -595,6 +628,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
||||
}
|
||||
case "wecom":
|
||||
return validateWecomNotificationConfig(config)
|
||||
case "lark":
|
||||
return validateLarkNotificationConfig(config)
|
||||
case "telegram":
|
||||
token := configString(config, "bot_token")
|
||||
if token == "" || token == store.SecretMask {
|
||||
@@ -848,6 +883,8 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
// Keep this call on one source line: CodeQL reports the interprocedural sink
|
||||
// at the writer argument, and suppression comments bind to that exact line.
|
||||
// codeql[go/email-injection]
|
||||
// CodeQL [go/email-injection]
|
||||
// lgtm[go/email-injection]
|
||||
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("write SMTP test message: %w", err)
|
||||
@@ -954,7 +991,7 @@ func validateOutboundURL(
|
||||
}
|
||||
|
||||
func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, false)
|
||||
parsed, err := parseProxyURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -964,6 +1001,26 @@ func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, er
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseProxyURL parses an HTTP(S) proxy URL. Unlike parseOutboundURL, it
|
||||
// permits embedded userinfo (http://user:pass@host:port) because HTTP proxies
|
||||
// commonly authenticate with Proxy-Authorization derived from the URL.
|
||||
func parseProxyURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||
return nil, errors.New("proxy must be an absolute HTTP URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, errors.New("proxy URL must use HTTP or HTTPS")
|
||||
}
|
||||
if parsed.Port() != "" {
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return nil, errors.New("proxy URL has an invalid port")
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||
|
||||
@@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m
|
||||
return response
|
||||
}
|
||||
|
||||
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
|
||||
func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
||||
if recorder.Code != http.StatusOK {
|
||||
@@ -179,6 +179,73 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||
secret := "lark-signing-secret"
|
||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||
first, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) {
|
||||
t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||
if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask {
|
||||
t.Fatalf("redacted Lark config = %#v", lark)
|
||||
}
|
||||
|
||||
second, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": store.SecretMask, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "lark")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) {
|
||||
t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||
"signing_enabled": false, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||
if _, exists := lark["secret"]; exists {
|
||||
t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
@@ -232,6 +299,45 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored"
|
||||
storedConfig, err := json.Marshal(map[string]any{
|
||||
"url": storedURL,
|
||||
"signing_enabled": true,
|
||||
"secret": "stored-signing-secret",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
||||
Channel: "lark",
|
||||
Config: storedConfig,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"url": store.SecretMask,
|
||||
"signing_enabled": true,
|
||||
"secret": store.SecretMask,
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved["url"] != storedURL {
|
||||
t.Fatalf("resolved URL = %#v", resolved["url"])
|
||||
}
|
||||
if resolved["secret"] != "stored-signing-secret" {
|
||||
t.Fatalf("resolved secret = %#v", resolved["secret"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
cases := []struct {
|
||||
@@ -284,6 +390,41 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "invalid Lark payload template",
|
||||
body: `{"lark":{"enabled":true,"payload_template":"[]"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "enabled Lark config without webhook URL",
|
||||
body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "enabled Lark signing without secret",
|
||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "changed Lark URL with masked signing secret",
|
||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "insecure Lark group bot URL",
|
||||
body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "non-Lark group bot URL",
|
||||
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "webhook URL with embedded credentials",
|
||||
body: `{"webhook":{"enabled":true,"urls":["http://user:[email protected]"]}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "null body",
|
||||
body: `null`,
|
||||
@@ -858,6 +999,41 @@ func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProxyAcceptsAuthenticatedURL(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"http://user:[email protected]:8080"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
data, ok := response["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("data missing: %#v", response)
|
||||
}
|
||||
telegram, ok := data["telegram"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("telegram response missing: %#v", data)
|
||||
}
|
||||
if telegram["proxy"] != "http://user:[email protected]:8080" {
|
||||
t.Fatalf("proxy not preserved: %#v", telegram["proxy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProxyRejectsMalformedURL(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"not-a-url"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
detail, ok := response["error"].(map[string]any)
|
||||
if !ok || detail["code"] != "invalid_notification_config" {
|
||||
t.Fatalf("error = %#v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil)
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
const smsNotificationPollInterval = 2 * time.Second
|
||||
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"}
|
||||
|
||||
type smsNotification struct {
|
||||
DeviceID string
|
||||
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
||||
|
||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "bark", "email", "webhook", "wecom":
|
||||
case "bark", "email", "webhook", "wecom", "lark":
|
||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,6 +204,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
||||
return sendWebhookSMSNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkSMSValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,31 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) {
|
||||
message := smsNotification{
|
||||
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||
Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello",
|
||||
}
|
||||
smsValues := larkSMSValues(message)
|
||||
if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" ||
|
||||
smsValues["message"] != message.Text() || smsValues["content"] != "hello" ||
|
||||
smsValues["device_label"] != "EC20" {
|
||||
t.Fatalf("Lark SMS values = %#v", smsValues)
|
||||
}
|
||||
|
||||
taskValues := larkAutomaticTaskValues(automaticTaskNotification{
|
||||
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||
})
|
||||
if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" {
|
||||
t.Fatalf("Lark automatic task values = %#v", taskValues)
|
||||
}
|
||||
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||
if taskValues[name] != "" {
|
||||
t.Fatalf("%s = %q, want empty", name, taskValues[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
valid := map[string]map[string]any{
|
||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||
@@ -76,6 +101,12 @@ func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
},
|
||||
"lark": {
|
||||
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret",
|
||||
"signing_enabled": true,
|
||||
"secret": "signing-secret",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
},
|
||||
}
|
||||
for channel, config := range valid {
|
||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||
|
||||
@@ -1981,7 +1981,7 @@ func (bot *telegramBot) handleATCommand(ctx context.Context, config telegramRunt
|
||||
|
||||
func (bot *telegramBot) executeATCommand(ctx context.Context, deviceID, command string) (string, error) {
|
||||
command = strings.TrimSpace(command)
|
||||
if err := validateATCommand(command); err != nil {
|
||||
if err := validateATCommand(command, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _, physicalID, err := bot.device(deviceID)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
)
|
||||
|
||||
// ussdSessionStore is the HTTP-layer counterpart of device.Manager's USSD
|
||||
// session map. A USSI awaiting-input reply opens a token here so the existing
|
||||
// continue/cancel endpoints keep working; the token only records which device
|
||||
// the dialog belongs to — the IMS session owns the actual network dialog.
|
||||
type ussdSessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]ussdServerSession
|
||||
}
|
||||
|
||||
type ussdServerSession struct {
|
||||
deviceID string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func newUSSDSessionStore() ussdSessionStore {
|
||||
return ussdSessionStore{sessions: make(map[string]ussdServerSession)}
|
||||
}
|
||||
|
||||
func (store *ussdSessionStore) open(deviceID string) string {
|
||||
var token [8]byte
|
||||
_, _ = rand.Read(token[:])
|
||||
id := hex.EncodeToString(token[:])
|
||||
store.mu.Lock()
|
||||
if store.sessions == nil {
|
||||
store.sessions = make(map[string]ussdServerSession)
|
||||
}
|
||||
store.sessions[id] = ussdServerSession{deviceID: deviceID, createdAt: time.Now().UTC()}
|
||||
store.mu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func (store *ussdSessionStore) device(sessionID string) (string, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
session, ok := store.sessions[strings.TrimSpace(sessionID)]
|
||||
if !ok {
|
||||
return "", device.ErrUSSDSessionNotFound
|
||||
}
|
||||
return session.deviceID, nil
|
||||
}
|
||||
|
||||
func (store *ussdSessionStore) drop(sessionID string) {
|
||||
store.mu.Lock()
|
||||
delete(store.sessions, strings.TrimSpace(sessionID))
|
||||
store.mu.Unlock()
|
||||
}
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
var wecomTemplateVariableNames = []string{
|
||||
@@ -25,6 +28,10 @@ var wecomTemplateVariableNames = []string{
|
||||
"time",
|
||||
}
|
||||
|
||||
var wecomWebhookHosts = map[string]struct{}{
|
||||
"qyapi.weixin.qq.com": {},
|
||||
}
|
||||
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
@@ -46,6 +53,46 @@ func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, er
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func parseWecomWebhookURL(raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonicalHost := strings.ToLower(parsed.Hostname())
|
||||
if _, ok := wecomWebhookHosts[canonicalHost]; !ok {
|
||||
return nil, errors.New("WeCom bot webhook must use qyapi.weixin.qq.com")
|
||||
}
|
||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||
return nil, errors.New("WeCom bot webhook must use the default HTTPS port")
|
||||
}
|
||||
if parsed.Path != "/cgi-bin/webhook/send" {
|
||||
return nil, errors.New("WeCom bot webhook path must be /cgi-bin/webhook/send")
|
||||
}
|
||||
key := parsed.Query().Get("key")
|
||||
if key == "" || strings.ContainsAny(key, " \t\r\n/") {
|
||||
return nil, errors.New("WeCom bot webhook key parameter is missing or invalid")
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("key", key)
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: canonicalHost,
|
||||
Path: "/cgi-bin/webhook/send",
|
||||
RawQuery: query.Encode(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateWecomWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseWecomWebhookURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct {
|
||||
ErrCode *int `json:"errcode"`
|
||||
@@ -102,6 +149,13 @@ func validateWecomNotificationConfig(config map[string]any) error {
|
||||
if len(urls) > 8 {
|
||||
return errors.New("wecom.urls cannot contain more than 8 URLs")
|
||||
}
|
||||
for _, rawURL := range urls {
|
||||
if rawURL != store.SecretMask {
|
||||
if _, err := parseWecomWebhookURL(rawURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
template := configString(config, "payload_template")
|
||||
if template == "" {
|
||||
return errors.New("wecom.payload_template is required")
|
||||
@@ -120,7 +174,7 @@ func sendWecomNotification(ctx context.Context, config map[string]any, values we
|
||||
return err
|
||||
}
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
parsed, err := validateWecomWebhookURL(ctx, destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,6 +184,8 @@ func sendWecomNotification(ctx context.Context, config map[string]any, values we
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
// Target host is restricted to the WeCom webhook domain whitelist.
|
||||
// codeql[go/uncontrolled-data-in-network-request]
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send WeCom notification: %w", err)
|
||||
|
||||
@@ -917,6 +917,56 @@ func TestNotificationArraySecretPreservation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "lark", Enabled: true,
|
||||
Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err := database.NotificationSetting(ctx, "lark")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redacted["url"] != SecretMask || redacted["secret"] != SecretMask {
|
||||
t.Fatalf("redacted Lark config = %#v", redacted)
|
||||
}
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "lark", Enabled: true,
|
||||
Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err = database.NotificationSetting(ctx, "lark")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) {
|
||||
t.Fatalf("stored Lark config = %s", setting.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) {
|
||||
setting := NotificationSetting{
|
||||
Config: json.RawMessage(`{"url":"","secret":""}`),
|
||||
SensitiveFields: []string{"url", "secret"},
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redacted["url"] != "" || redacted["secret"] != "" {
|
||||
t.Fatalf("empty sensitive values were masked: %#v", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
|
||||
@@ -610,6 +610,9 @@ func mergeJSONSecrets(
|
||||
func redactJSONValue(value any, replacement string) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed == "" {
|
||||
return ""
|
||||
}
|
||||
return replacement
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
|
||||
+46
-17
@@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
value, err := normalizeDeviceProxyBinding(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
createdAt := value.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
}
|
||||
updatedAt := value.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = now
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
||||
profile_name = excluded.profile_name,
|
||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||
updated_at = excluded.updated_at
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertDeviceProxyBindingIfAbsent materializes a default route without ever
|
||||
// replacing an explicit (or concurrently-created) ICCID binding.
|
||||
func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) {
|
||||
value, err := normalizeDeviceProxyBinding(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO NOTHING
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
if value.UpdatedAt.IsZero() {
|
||||
value.UpdatedAt = now
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
||||
return deviceProxyBinding(s.db.QueryRowContext(
|
||||
ctx,
|
||||
|
||||
@@ -24,6 +24,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
||||
return []string{"token"}
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
case "lark":
|
||||
return []string{"url", "secret"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -94,9 +95,15 @@ func mergeConcatSegment(
|
||||
}
|
||||
}
|
||||
}
|
||||
prior, alreadyHad := parts[sequence]
|
||||
changed = !alreadyHad || prior != segmentBody
|
||||
// Some IMS stacks hand us a cumulative segment: sequence 2 contains the
|
||||
// already-decoded text of sequence 1 followed by its own payload. Keep a
|
||||
// snapshot so normalizing that representation remains idempotent on a later
|
||||
// redelivery of the same segment.
|
||||
previousParts := maps.Clone(parts)
|
||||
normalizeCumulativeConcatParts(previousParts)
|
||||
parts[sequence] = segmentBody
|
||||
normalizeCumulativeConcatParts(parts)
|
||||
changed = !maps.Equal(previousParts, parts)
|
||||
|
||||
sequences := make([]int, 0, len(parts))
|
||||
for n := range parts {
|
||||
@@ -130,3 +137,24 @@ func mergeConcatSegment(
|
||||
}
|
||||
return joined.String(), json.RawMessage(encoded), changed, nil
|
||||
}
|
||||
|
||||
// normalizeCumulativeConcatParts converts cumulative IMS segment bodies back
|
||||
// into ordinary per-segment bodies. It only removes an exact, non-empty prefix
|
||||
// assembled from every preceding sequence starting at 1, and only when the
|
||||
// current value also contains additional text. That deliberately leaves equal
|
||||
// repeated segments and incomplete/out-of-order prefixes untouched.
|
||||
func normalizeCumulativeConcatParts(parts map[int]string) {
|
||||
var prefix strings.Builder
|
||||
for sequence := 1; ; sequence++ {
|
||||
text, ok := parts[sequence]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assembled := prefix.String()
|
||||
if assembled != "" && len(text) > len(assembled) && strings.HasPrefix(text, assembled) {
|
||||
text = strings.TrimPrefix(text, assembled)
|
||||
parts[sequence] = text
|
||||
}
|
||||
prefix.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,62 @@ func TestMergeConcatSegmentRedeliveryIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentNormalizesCumulativeIMSPart(t *testing.T) {
|
||||
first := strings.Repeat("安全提醒", 17)
|
||||
want := first + "请通过官方渠道核实。"
|
||||
_, extra, _, err := mergeConcatSegment(nil, first, concatExtra(t, 8, 2, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, normalized, changed, err := mergeConcatSegment(extra, want, concatExtra(t, 8, 2, 2))
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("cumulative segment: body=%q changed=%v err=%v", body, changed, err)
|
||||
}
|
||||
if body != want {
|
||||
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||
}
|
||||
|
||||
// Redelivering the cumulative wire representation must compare equal to the
|
||||
// normalized stored representation and must not churn the durable row id.
|
||||
body, _, changed, err = mergeConcatSegment(normalized, want, concatExtra(t, 8, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed || body != want {
|
||||
t.Fatalf("redelivery: body=%q changed=%v, want %q/false", body, changed, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentNormalizesCumulativeIMSPartOutOfOrder(t *testing.T) {
|
||||
first := strings.Repeat("甲", 67)
|
||||
want := first + "尾段"
|
||||
_, extra, _, err := mergeConcatSegment(nil, want, concatExtra(t, 12, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, changed, err := mergeConcatSegment(extra, first, concatExtra(t, 12, 2, 1))
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("out-of-order segment: body=%q changed=%v err=%v", body, changed, err)
|
||||
}
|
||||
if body != want {
|
||||
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentKeepsEqualRepeatedPart(t *testing.T) {
|
||||
_, extra, _, err := mergeConcatSegment(nil, "重复", concatExtra(t, 13, 2, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _, err := mergeConcatSegment(extra, "重复", concatExtra(t, 13, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != "重复重复" {
|
||||
t.Fatalf("body = %q, want intentional equal segments preserved", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
||||
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLongSMSReassemblySurvivesServiceRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
const (
|
||||
deviceID = "dajiang"
|
||||
imei = "867394042309830"
|
||||
peer = "+447700900123"
|
||||
)
|
||||
messageID := StableConcatMessageID("ims", imei, deviceID, peer, 27, 2)
|
||||
|
||||
database, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustSaveDevice(t, database, deviceID, "大疆")
|
||||
first, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第一段:安全提醒,",
|
||||
Timestamp: time.Unix(1_700_000_000, 0).UTC(), Status: "received", Source: "ims",
|
||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
|
||||
t.Fatal("partial message must not be ready before restart")
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err = Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
second, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||
Timestamp: time.Unix(1_700_000_030, 0).UTC(), Status: "received", Source: "ims",
|
||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Body != "第一段:安全提醒,第二段:请通过官方渠道核实。" ||
|
||||
!ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
|
||||
t.Fatalf("reassembled message after restart = %#v", second)
|
||||
}
|
||||
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: deviceID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].ID != second.ID {
|
||||
t.Fatalf("stored messages after restart = %#v, want one merged row", messages)
|
||||
}
|
||||
redelivered, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||
Status: "received", Source: "ims", PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redelivered.ID != second.ID || redelivered.Body != second.Body {
|
||||
t.Fatalf("redelivery duplicated or changed message: %#v", redelivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartDeliveryReportsSurviveServiceRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
database, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustSaveDevice(t, database, "dajiang", "大疆")
|
||||
extra := json.RawMessage(`{"transport":"ims","part_results":[{"reference":51},{"reference":52}]}`)
|
||||
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: "ims-submit-restart", DeviceID: "dajiang", IMSI: "23433",
|
||||
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
|
||||
Status: "accepted_by_ims", Source: "ims", PartsTotal: 2,
|
||||
DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err = Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||
MessageReference: 51, StatusCode: 0, DeliveryState: "delivered",
|
||||
})
|
||||
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
|
||||
t.Fatalf("first report after restart = (%#v, %v)", first, err)
|
||||
}
|
||||
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||
MessageReference: 52, StatusCode: 0, DeliveryState: "delivered",
|
||||
})
|
||||
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
|
||||
t.Fatalf("second report after restart = (%#v, %v)", second, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectSystemdUnitUsesExplicitOverride(t *testing.T) {
|
||||
t.Setenv("VOCAT_SYSTEMD_UNIT", "vocat-test.service")
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if got := detectSystemdUnit(logger); got != "vocat-test.service" {
|
||||
t.Fatalf("detectSystemdUnit() = %q, want vocat-test.service", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdUnitFromCgroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
}{
|
||||
{name: "cgroup v2", data: "0::/system.slice/vocat-test.service\n", want: "vocat-test.service"},
|
||||
{name: "legacy", data: "1:name=systemd:/system.slice/[email protected]\n", want: "[email protected]"},
|
||||
{name: "no service", data: "0::/user.slice/user-1000.slice/session-1.scope\n", want: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := systemdUnitFromCgroup(test.data); got != test.want {
|
||||
t.Fatalf("systemdUnitFromCgroup() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSystemdUnitRejectsArgumentsAndPaths(t *testing.T) {
|
||||
for _, value := range []string{"vocat", "../vocat.service", "vocat.service --now", "vocat.service/other"} {
|
||||
if validSystemdUnit.MatchString(value) {
|
||||
t.Fatalf("validSystemdUnit unexpectedly accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -320,17 +322,63 @@ func RestartService(logger *slog.Logger) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||
}
|
||||
unit := detectSystemdUnit(logger)
|
||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||
return fmt.Errorf("systemctl restart vocat: %w", err)
|
||||
return fmt.Errorf("systemctl restart %s: %w", unit, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var validSystemdUnit = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`)
|
||||
|
||||
func detectSystemdUnit(logger *slog.Logger) string {
|
||||
if configured := strings.TrimSpace(os.Getenv("VOCAT_SYSTEMD_UNIT")); validSystemdUnit.MatchString(configured) {
|
||||
return configured
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||
if unit := systemdUnitFromCgroup(string(data)); unit != "" {
|
||||
return unit
|
||||
}
|
||||
}
|
||||
// Some cgroup namespaces hide the unit name. Query loaded services and
|
||||
// identify the unit whose MainPID is this process before falling back.
|
||||
list := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain")
|
||||
if output, err := list.Output(); err == nil {
|
||||
pid := strconv.Itoa(os.Getpid())
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 || !validSystemdUnit.MatchString(fields[0]) {
|
||||
continue
|
||||
}
|
||||
show := exec.Command("systemctl", "show", fields[0], "--property=MainPID", "--value")
|
||||
if value, showErr := show.Output(); showErr == nil && strings.TrimSpace(string(value)) == pid {
|
||||
return fields[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Warn("could not identify the current systemd unit; using vocat.service", "hint", "set VOCAT_SYSTEMD_UNIT for a custom unit")
|
||||
}
|
||||
return "vocat.service"
|
||||
}
|
||||
|
||||
func systemdUnitFromCgroup(data string) string {
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
for _, part := range strings.Split(line, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if validSystemdUnit.MatchString(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveDefaultTarget returns the conventional install path when present,
|
||||
// falling back to the running executable. This lets `vocat update` "just work"
|
||||
// on the standard systemd host without flags.
|
||||
|
||||
@@ -1,52 +1,959 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const att310280EPDG = "epdg.epc.att.net"
|
||||
const (
|
||||
CarrierProfileSchemaVersion = 1
|
||||
CarrierProfileStandard = "standard-3gpp"
|
||||
IKEProposalModern = "modern"
|
||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||
IMSProfileStandard = "standard"
|
||||
IMSProfileO2Germany = "o2-germany"
|
||||
IMSProfileATT = "att"
|
||||
)
|
||||
|
||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
||||
// VoWiFi access gateway.
|
||||
// CarrierProfile contains only interoperability choices that cannot be
|
||||
// reliably discovered from the SIM or negotiated with the network. All
|
||||
// protocol layers consume this common result so their carrier handling cannot
|
||||
// drift into separate MCC/MNC switch statements.
|
||||
type CarrierProfile struct {
|
||||
ID string
|
||||
MatchSource string
|
||||
RouteMCC string
|
||||
RouteMNC string
|
||||
EPDG string
|
||||
IKEProposal string
|
||||
AdvertiseEAPOnly bool
|
||||
AllowSMSWithoutContactConfirmation bool
|
||||
IMSRegisterOptions IMSRegisterOptions
|
||||
IMSTransport string
|
||||
IMSIdentityProfile string
|
||||
IMSRegisterProfile string
|
||||
IMSIPSecEncryption string
|
||||
SMSCenter string
|
||||
PANICountry string
|
||||
PANINode string
|
||||
IMSDialURIScheme string
|
||||
IMSUserEqPhone bool
|
||||
IMSVoiceCodecs []string
|
||||
}
|
||||
|
||||
// IMSRegisterOptions carries carrier-specific SIP REGISTER header values.
|
||||
// Pointer fields distinguish "use default" (nil) from "explicitly omit" ("").
|
||||
type IMSRegisterOptions struct {
|
||||
ContactFormat string
|
||||
ExpirySeconds int
|
||||
ContactExtraTags []string
|
||||
SupportedHeader *string
|
||||
AllowHeader *string
|
||||
UserAgent string
|
||||
PPreferredIdentity bool
|
||||
PVisitedNetworkID string
|
||||
PAccessNetworkInfo *string
|
||||
CellularNetworkInfo string
|
||||
AcceptContactTags []string
|
||||
}
|
||||
|
||||
const (
|
||||
IMSContactFormatStandard = "standard"
|
||||
IMSContactFormatATT = "att"
|
||||
)
|
||||
|
||||
type carrierProfileDocument struct {
|
||||
Version int `json:"version"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
}
|
||||
|
||||
type carrierProfileRule struct {
|
||||
ID string `json:"id"`
|
||||
Match carrierProfileMatch `json:"match,omitzero"`
|
||||
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
||||
Route carrierProfileRoute `json:"route,omitzero"`
|
||||
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
||||
IKE carrierProfileIKE `json:"ike,omitzero"`
|
||||
IMS carrierProfileIMS `json:"ims,omitzero"`
|
||||
}
|
||||
|
||||
type carrierProfileMatch struct {
|
||||
HomePLMNs []string `json:"home_plmns,omitempty"`
|
||||
IMSIPrefixes []string `json:"imsi_prefixes,omitempty"`
|
||||
ICCIDPrefixes []string `json:"iccid_prefixes,omitempty"`
|
||||
SPNs []string `json:"spns,omitempty"`
|
||||
GID1Prefixes []string `json:"gid1_prefixes,omitempty"`
|
||||
GID2Prefixes []string `json:"gid2_prefixes,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileRoute struct {
|
||||
MCC string `json:"mcc,omitempty"`
|
||||
MNC string `json:"mnc,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileEPDG struct {
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
DNSHosts []string `json:"dns_hosts,omitempty"`
|
||||
DNSClientSubnet string `json:"dns_client_subnet,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileIKE struct {
|
||||
Proposal string `json:"proposal,omitempty"`
|
||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileIMS struct {
|
||||
Transport string `json:"transport,omitempty"`
|
||||
IdentityProfile string `json:"identity_profile,omitempty"`
|
||||
RegisterProfile string `json:"register_profile,omitempty"`
|
||||
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
||||
SMSCenter string `json:"sms_center,omitempty"`
|
||||
PANICountry string `json:"pani_country,omitempty"`
|
||||
PANINode string `json:"pani_node,omitempty"`
|
||||
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
||||
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
||||
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
||||
RegisterOptions carrierProfileRegisterOptions `json:"register_options,omitzero"`
|
||||
AllowSMSWithoutContactConfirmation *bool `json:"allow_sms_without_contact_confirmation,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileRegisterOptions struct {
|
||||
ContactFormat string `json:"contact_format,omitempty"`
|
||||
ExpirySeconds int `json:"expiry_seconds,omitempty"`
|
||||
ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
|
||||
SupportedHeader *string `json:"supported_header,omitempty"`
|
||||
AllowHeader *string `json:"allow_header,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
|
||||
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
|
||||
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
|
||||
CellularNetworkInfo string `json:"cellular_network_info,omitempty"`
|
||||
AcceptContactTags []string `json:"accept_contact_tags,omitempty"`
|
||||
}
|
||||
|
||||
//go:embed carrier_profiles.json
|
||||
var carrierProfilesJSON []byte
|
||||
|
||||
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||
|
||||
var externalCarrierProfiles = struct {
|
||||
sync.RWMutex
|
||||
rules []carrierProfileRule
|
||||
}{}
|
||||
|
||||
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||
rules, err := loadCarrierProfiles(encoded)
|
||||
if err != nil {
|
||||
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func loadCarrierProfiles(encoded []byte) ([]carrierProfileRule, error) {
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if document.Version != CarrierProfileSchemaVersion {
|
||||
return nil, fmt.Errorf("unsupported carrier profile version %d", document.Version)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(document.Profiles))
|
||||
for index := range document.Profiles {
|
||||
rule := &document.Profiles[index]
|
||||
rule.ID = strings.TrimSpace(rule.ID)
|
||||
if rule.ID == "" {
|
||||
return nil, fmt.Errorf("carrier profile %d ID is empty", index)
|
||||
}
|
||||
if _, duplicate := seen[rule.ID]; duplicate {
|
||||
return nil, errors.New("duplicate carrier profile " + rule.ID)
|
||||
}
|
||||
seen[rule.ID] = struct{}{}
|
||||
if !validCarrierProfileRule(*rule) {
|
||||
return nil, errors.New("invalid carrier profile " + rule.ID)
|
||||
}
|
||||
}
|
||||
return document.Profiles, nil
|
||||
}
|
||||
|
||||
// LoadCarrierProfileDirectory replaces the installed profile set with all
|
||||
// valid JSON documents in dir. A missing directory is an empty set. Profiles
|
||||
// are sorted by filename; later profiles win only when selector specificity is
|
||||
// equal, so a broad installed PLMN rule cannot hide a constrained MVNO rule.
|
||||
func LoadCarrierProfileDirectory(dir string) error {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return errors.New("carrier profile directory is empty")
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
externalCarrierProfiles.Lock()
|
||||
externalCarrierProfiles.rules = nil
|
||||
externalCarrierProfiles.Unlock()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read carrier profile directory %q: %w", dir, err)
|
||||
}
|
||||
if len(entries) > 256 {
|
||||
return fmt.Errorf("carrier profile directory %q contains %d entries; maximum is 256", dir, len(entries))
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
loaded := make([]carrierProfileRule, 0, len(entries))
|
||||
seen := make(map[string]string)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat carrier profile %q: %w", path, err)
|
||||
}
|
||||
if info.Size() > 1<<20 {
|
||||
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open carrier profile %q: %w", path, err)
|
||||
}
|
||||
encoded, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read carrier profile %q: %w", path, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close carrier profile %q: %w", path, closeErr)
|
||||
}
|
||||
if len(encoded) > 1<<20 {
|
||||
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||
}
|
||||
rules, err := loadCarrierProfiles(encoded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load carrier profile %q: %w", path, err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if previous := seen[rule.ID]; previous != "" {
|
||||
return fmt.Errorf("carrier profile %q is duplicated in %q and %q", rule.ID, previous, path)
|
||||
}
|
||||
seen[rule.ID] = path
|
||||
loaded = append(loaded, rule)
|
||||
}
|
||||
}
|
||||
externalCarrierProfiles.Lock()
|
||||
externalCarrierProfiles.rules = loaded
|
||||
externalCarrierProfiles.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func carrierProfilesSnapshot() []carrierProfileRule {
|
||||
externalCarrierProfiles.RLock()
|
||||
defer externalCarrierProfiles.RUnlock()
|
||||
result := make([]carrierProfileRule, 0, len(builtinCarrierProfiles)+len(externalCarrierProfiles.rules))
|
||||
result = append(result, builtinCarrierProfiles...)
|
||||
result = append(result, externalCarrierProfiles.rules...)
|
||||
return result
|
||||
}
|
||||
|
||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
capacity := len(rule.MatchAny)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
capacity++
|
||||
}
|
||||
matches := make([]carrierProfileMatch, 0, capacity)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
matches = append(matches, rule.MatchAny...)
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, match := range matches {
|
||||
if emptyCarrierProfileMatch(match) {
|
||||
return false
|
||||
}
|
||||
for _, plmn := range match.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range match.IMSIPrefixes {
|
||||
if len(prefix) < 5 || len(prefix) > 18 || !decimalString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range match.ICCIDPrefixes {
|
||||
if len(prefix) < 5 || len(prefix) > 22 || !decimalString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range append(append([]string(nil), match.GID1Prefixes...), match.GID2Prefixes...) {
|
||||
if len(prefix) < 1 || len(prefix) > 64 || !hexString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, spn := range match.SPNs {
|
||||
if strings.TrimSpace(spn) == "" || len(spn) > 128 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
||||
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
||||
return false
|
||||
}
|
||||
if proposal := strings.TrimSpace(rule.IKE.Proposal); proposal != "" &&
|
||||
proposal != IKEProposalModern && proposal != IKEProposalLegacy {
|
||||
return false
|
||||
}
|
||||
if transport := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); transport != "" &&
|
||||
transport != "tcp" && transport != "udp" {
|
||||
return false
|
||||
}
|
||||
if encryption := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); encryption != "" &&
|
||||
encryption != "aes-cbc" && encryption != "null" {
|
||||
return false
|
||||
}
|
||||
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
||||
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
||||
return false
|
||||
}
|
||||
if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" {
|
||||
return false
|
||||
}
|
||||
for _, codec := range rule.IMS.VoiceCodecs {
|
||||
switch strings.ToUpper(strings.TrimSpace(codec)) {
|
||||
case "PCMA", "PCMU", "AMR", "AMR-WB":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if rule.IMS.RegisterOptions.ExpirySeconds != 0 &&
|
||||
(rule.IMS.RegisterOptions.ExpirySeconds < 60 || rule.IMS.RegisterOptions.ExpirySeconds > 86400) {
|
||||
return false
|
||||
}
|
||||
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
|
||||
format != IMSContactFormatStandard && format != IMSContactFormatATT {
|
||||
return false
|
||||
}
|
||||
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, value := range []*string{rule.IMS.RegisterOptions.SupportedHeader, rule.IMS.RegisterOptions.AllowHeader, rule.IMS.RegisterOptions.PAccessNetworkInfo} {
|
||||
if value != nil && strings.ContainsAny(*value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, value := range rule.IMS.RegisterOptions.AcceptContactTags {
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func emptyCarrierProfileMatch(match carrierProfileMatch) bool {
|
||||
return len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0
|
||||
}
|
||||
|
||||
func hexString(value string) bool {
|
||||
for _, item := range value {
|
||||
if item >= '0' && item <= '9' || item >= 'a' && item <= 'f' || item >= 'A' && item <= 'F' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func decimalString(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range value {
|
||||
if item < '0' || item > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||
// PLMN without weakening the default match for unrelated subscriptions.
|
||||
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
resolved := CarrierProfile{
|
||||
ID: CarrierProfileStandard,
|
||||
MatchSource: "standard",
|
||||
IKEProposal: IKEProposalModern,
|
||||
AdvertiseEAPOnly: true,
|
||||
IMSIdentityProfile: IMSProfileStandard,
|
||||
IMSRegisterProfile: IMSProfileStandard,
|
||||
IMSIPSecEncryption: "aes-cbc",
|
||||
IMSDialURIScheme: "tel",
|
||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||
}
|
||||
bestScore := -1
|
||||
for _, rule := range carrierProfilesSnapshot() {
|
||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||
if !matched || score < bestScore {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
resolved = applyCarrierProfileRule(resolved, rule, source, identity)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// matchCarrierProfileRule evaluates each selector set as an alternative. This
|
||||
// mirrors carrier-bundle and Android carrier-ID semantics: fields inside one
|
||||
// selector are ANDed, while separate selector records for the same brand are
|
||||
// ORed (for example, giffgaff can be identified by either GID1 or SPN).
|
||||
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
||||
bestScore := -1
|
||||
bestSource := ""
|
||||
capacity := len(rule.MatchAny)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
capacity++
|
||||
}
|
||||
matches := make([]carrierProfileMatch, 0, capacity)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
matches = append(matches, rule.MatchAny...)
|
||||
for _, match := range matches {
|
||||
score, source, matched := matchCarrierProfile(match, identity)
|
||||
if matched && score > bestScore {
|
||||
bestScore = score
|
||||
bestSource = source
|
||||
}
|
||||
}
|
||||
return bestScore, bestSource, bestScore >= 0
|
||||
}
|
||||
|
||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
||||
score := 0
|
||||
sources := make([]string, 0, 6)
|
||||
hasHomePLMNMatch := false
|
||||
if len(match.HomePLMNs) > 0 {
|
||||
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||
if wanted != "" && matchesAny(match.HomePLMNs, func(value string) bool {
|
||||
return canonicalPLMNValue(value) == wanted
|
||||
}) {
|
||||
score += 100
|
||||
sources = append(sources, "hplmn")
|
||||
hasHomePLMNMatch = true
|
||||
}
|
||||
}
|
||||
hasSelectorMatch := false
|
||||
for _, selector := range []struct {
|
||||
name string
|
||||
weight int
|
||||
values []string
|
||||
actual string
|
||||
foldCase bool
|
||||
}{
|
||||
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
|
||||
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
|
||||
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
|
||||
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
|
||||
} {
|
||||
if len(selector.values) == 0 {
|
||||
continue
|
||||
}
|
||||
actual := strings.TrimSpace(selector.actual)
|
||||
if actual != "" && matchesAny(selector.values, func(prefix string) bool {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if selector.foldCase {
|
||||
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
||||
}
|
||||
return strings.HasPrefix(actual, prefix)
|
||||
}) {
|
||||
score += selector.weight
|
||||
sources = append(sources, selector.name)
|
||||
hasSelectorMatch = true
|
||||
} else if !hasHomePLMNMatch {
|
||||
return 0, "", false
|
||||
}
|
||||
}
|
||||
if len(match.SPNs) > 0 {
|
||||
spn := strings.TrimSpace(identity.SPN)
|
||||
if spn != "" && matchesAny(match.SPNs, func(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||
}) {
|
||||
score += 20
|
||||
sources = append(sources, "spn")
|
||||
hasSelectorMatch = true
|
||||
}
|
||||
}
|
||||
if !hasHomePLMNMatch && !hasSelectorMatch {
|
||||
return 0, "", false
|
||||
}
|
||||
return score, strings.Join(sources, "+"), score > 0
|
||||
}
|
||||
|
||||
func matchesAny(values []string, match func(string) bool) bool {
|
||||
for _, value := range values {
|
||||
if match(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string, identity SIMIdentity) CarrierProfile {
|
||||
base.ID = rule.ID
|
||||
base.MatchSource = source
|
||||
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
||||
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
||||
if base.RouteMCC == "" {
|
||||
currentPLMN := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||
if currentPLMN != "" {
|
||||
for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) {
|
||||
for _, plmn := range m.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == currentPLMN {
|
||||
base.RouteMCC = strings.TrimSpace(identity.HomeMCC)
|
||||
base.RouteMNC = strings.TrimSpace(identity.HomeMNC)
|
||||
break
|
||||
}
|
||||
}
|
||||
if base.RouteMCC != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if base.RouteMCC == "" {
|
||||
for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) {
|
||||
for _, plmn := range m.HomePLMNs {
|
||||
plmn = canonicalPLMNValue(plmn)
|
||||
if len(plmn) >= 5 {
|
||||
base.RouteMCC = plmn[:3]
|
||||
base.RouteMNC = plmn[3:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if base.RouteMCC != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
||||
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
||||
base.IKEProposal = value
|
||||
}
|
||||
if rule.IKE.AdvertiseEAPOnly != nil {
|
||||
base.AdvertiseEAPOnly = *rule.IKE.AdvertiseEAPOnly
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); value != "" {
|
||||
base.IMSTransport = value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.IMS.IdentityProfile); value != "" {
|
||||
base.IMSIdentityProfile = value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.IMS.RegisterProfile); value != "" {
|
||||
base.IMSRegisterProfile = value
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); value != "" {
|
||||
base.IMSIPSecEncryption = value
|
||||
}
|
||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
||||
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
||||
base.IMSDialURIScheme = value
|
||||
}
|
||||
if rule.IMS.UserEqPhone != nil {
|
||||
base.IMSUserEqPhone = *rule.IMS.UserEqPhone
|
||||
}
|
||||
if len(rule.IMS.VoiceCodecs) > 0 {
|
||||
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
||||
}
|
||||
if rule.IMS.AllowSMSWithoutContactConfirmation != nil {
|
||||
base.AllowSMSWithoutContactConfirmation = *rule.IMS.AllowSMSWithoutContactConfirmation
|
||||
}
|
||||
base.IMSRegisterOptions = applyRegisterOptions(base.IMSRegisterOptions, rule.IMS.RegisterOptions)
|
||||
return base
|
||||
}
|
||||
|
||||
func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOptions) IMSRegisterOptions {
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.ContactFormat)); value != "" {
|
||||
base.ContactFormat = value
|
||||
}
|
||||
if rule.ExpirySeconds != 0 {
|
||||
base.ExpirySeconds = rule.ExpirySeconds
|
||||
}
|
||||
if len(rule.ContactExtraTags) > 0 {
|
||||
base.ContactExtraTags = append([]string(nil), rule.ContactExtraTags...)
|
||||
}
|
||||
if rule.SupportedHeader != nil {
|
||||
value := strings.TrimSpace(*rule.SupportedHeader)
|
||||
base.SupportedHeader = &value
|
||||
}
|
||||
if rule.AllowHeader != nil {
|
||||
value := strings.TrimSpace(*rule.AllowHeader)
|
||||
base.AllowHeader = &value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.UserAgent); value != "" {
|
||||
base.UserAgent = value
|
||||
}
|
||||
if rule.PPreferredIdentity {
|
||||
base.PPreferredIdentity = true
|
||||
}
|
||||
if value := strings.TrimSpace(rule.PVisitedNetworkID); value != "" {
|
||||
base.PVisitedNetworkID = value
|
||||
}
|
||||
if rule.PAccessNetworkInfo != nil {
|
||||
value := strings.TrimSpace(*rule.PAccessNetworkInfo)
|
||||
base.PAccessNetworkInfo = &value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.CellularNetworkInfo); value != "" {
|
||||
base.CellularNetworkInfo = value
|
||||
}
|
||||
if len(rule.AcceptContactTags) > 0 {
|
||||
base.AcceptContactTags = append([]string(nil), rule.AcceptContactTags...)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func normalizeVoiceCodecs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canonicalPLMN(mcc, mnc string) string {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) {
|
||||
return ""
|
||||
}
|
||||
for len(mnc) < 3 {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
return mcc + mnc
|
||||
}
|
||||
|
||||
func canonicalPLMNValue(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "/", ""))
|
||||
if len(value) != 5 && len(value) != 6 {
|
||||
return ""
|
||||
}
|
||||
return canonicalPLMN(value[:3], value[3:])
|
||||
}
|
||||
|
||||
// AssignedRoutePLMN remains available to callers that only have the legacy
|
||||
// identifier pair. New code resolves the complete SIMIdentity so SPN/GID
|
||||
// selectors can participate.
|
||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
switch {
|
||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
||||
return "234", "15", true
|
||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
||||
// the initial VoWiFi provisioning path.
|
||||
return "234", "30", true
|
||||
default:
|
||||
return "", "", false
|
||||
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
|
||||
if len(identity.IMSI) >= 5 {
|
||||
identity.HomeMCC = identity.IMSI[:3]
|
||||
for _, length := range []int{3, 2} {
|
||||
if len(identity.IMSI) < 3+length {
|
||||
continue
|
||||
}
|
||||
identity.HomeMNC = identity.IMSI[3 : 3+length]
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.RouteMCC != "" {
|
||||
return profile.RouteMCC, profile.RouteMNC, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
||||
// drift between protocol layers.
|
||||
func IsATT310280(identity SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
||||
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
|
||||
}
|
||||
|
||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
if strings.TrimSpace(identity.EPDG) != "" {
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.ID != CarrierProfileStandard && profile.RouteMCC != "" {
|
||||
identity.HomeMCC = profile.RouteMCC
|
||||
identity.HomeMNC = profile.RouteMNC
|
||||
if profile.EPDG != "" {
|
||||
identity.EPDG = profile.EPDG
|
||||
} else {
|
||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
||||
|
||||
if strings.TrimSpace(identity.ICCID) != "" {
|
||||
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
|
||||
imsiCountry := countryCodeForMCC(identity.HomeMCC)
|
||||
iccidCountry := countryCodeForMCC(mcc)
|
||||
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
|
||||
identity.HomeMCC = mcc
|
||||
identity.HomeMNC = mnc
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(identity.EPDG) == "" && identity.HomeMCC != "" && identity.HomeMNC != "" {
|
||||
identity.EPDG = standardEPDGHostname(identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func countryCodeForMCC(mcc string) string {
|
||||
switch strings.TrimSpace(mcc) {
|
||||
case "515":
|
||||
return "PH"
|
||||
case "262":
|
||||
return "DE"
|
||||
case "204":
|
||||
return "NL"
|
||||
case "234", "235":
|
||||
return "GB"
|
||||
case "460":
|
||||
return "CN"
|
||||
case "454":
|
||||
return "HK"
|
||||
case "466", "467":
|
||||
return "TW"
|
||||
case "525":
|
||||
return "SG"
|
||||
case "440", "441":
|
||||
return "JP"
|
||||
case "450":
|
||||
return "KR"
|
||||
case "310", "311", "312", "313", "314", "315", "316":
|
||||
return "US"
|
||||
case "302":
|
||||
return "CA"
|
||||
case "505":
|
||||
return "AU"
|
||||
case "208":
|
||||
return "FR"
|
||||
case "214":
|
||||
return "ES"
|
||||
case "222":
|
||||
return "IT"
|
||||
case "228":
|
||||
return "CH"
|
||||
case "232":
|
||||
return "AT"
|
||||
case "206":
|
||||
return "BE"
|
||||
case "260":
|
||||
return "PL"
|
||||
case "520":
|
||||
return "TH"
|
||||
case "510":
|
||||
return "ID"
|
||||
case "502":
|
||||
return "MY"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HomePLMNFromICCID infers the home MCC/MNC from well-known global ICCID prefixes.
|
||||
func HomePLMNFromICCID(iccid string) (mcc, mnc string, ok bool) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
if len(iccid) < 6 || !strings.HasPrefix(iccid, "89") {
|
||||
return "", "", false
|
||||
}
|
||||
prefixes := []struct {
|
||||
prefix string
|
||||
mcc string
|
||||
mnc string
|
||||
}{
|
||||
// Philippines
|
||||
{"896366", "515", "66"}, // DITO
|
||||
{"896302", "515", "02"}, // Globe
|
||||
{"896303", "515", "03"}, // Smart
|
||||
// Germany
|
||||
{"894920", "262", "02"}, // Vodafone DE
|
||||
{"894901", "262", "01"}, // Telekom DE
|
||||
{"894902", "262", "03"}, // O2 DE
|
||||
{"894903", "262", "03"},
|
||||
{"894907", "262", "07"},
|
||||
// United Kingdom
|
||||
{"894410", "234", "15"}, // Vodafone UK
|
||||
{"894415", "234", "15"},
|
||||
{"894411", "234", "30"}, // EE
|
||||
{"894430", "234", "30"},
|
||||
{"894420", "234", "20"}, // Three UK
|
||||
{"894421", "234", "10"}, // O2 UK
|
||||
// Netherlands
|
||||
{"8937204", "204", "04"}, // Vodafone NL
|
||||
{"893104", "204", "04"},
|
||||
{"893108", "204", "08"}, // KPN
|
||||
{"893116", "204", "16"}, // Odido
|
||||
// Hong Kong
|
||||
{"8985201", "454", "00"}, // CSL
|
||||
{"8985203", "454", "03"}, // 3 HK
|
||||
{"898523", "454", "03"},
|
||||
{"8985204", "454", "12"}, // CMHK
|
||||
{"8985206", "454", "06"}, // SmarTone
|
||||
// China
|
||||
{"898600", "460", "00"}, // China Mobile
|
||||
{"898602", "460", "00"},
|
||||
{"898604", "460", "00"},
|
||||
{"898607", "460", "00"},
|
||||
{"898601", "460", "01"}, // China Unicom
|
||||
{"898606", "460", "01"},
|
||||
{"898609", "460", "01"},
|
||||
{"898603", "460", "03"}, // China Telecom
|
||||
{"898605", "460", "03"},
|
||||
{"898611", "460", "03"},
|
||||
// Taiwan
|
||||
{"8988601", "466", "92"}, // Chunghwa
|
||||
{"8988602", "466", "97"}, // Taiwan Mobile
|
||||
{"8988603", "466", "01"}, // FarEasTone
|
||||
// Singapore
|
||||
{"896501", "525", "01"}, // Singtel
|
||||
{"896502", "525", "05"}, // StarHub
|
||||
{"896503", "525", "03"}, // M1
|
||||
{"896504", "525", "10"}, // SIMBA
|
||||
}
|
||||
for _, entry := range prefixes {
|
||||
if strings.HasPrefix(iccid, entry.prefix) {
|
||||
return entry.mcc, entry.mnc, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||
func EPDGDNSClientSubnet(host string) string {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
for _, rule := range carrierProfilesSnapshot() {
|
||||
for _, candidate := range rule.EPDG.DNSHosts {
|
||||
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
||||
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(host, ".mcc"); idx >= 0 && len(host) >= idx+7 {
|
||||
mcc := host[idx+4 : idx+7]
|
||||
if decimalString(mcc) {
|
||||
if subnet := MCCDefaultClientSubnet(mcc); subnet != "" {
|
||||
return subnet
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MCCDefaultClientSubnet returns the standard GeoDNS EDNS client subnet for a country MCC.
|
||||
func MCCDefaultClientSubnet(mcc string) string {
|
||||
switch strings.TrimSpace(mcc) {
|
||||
case "262": // Germany
|
||||
return "139.7.0.0/16"
|
||||
case "204": // Netherlands
|
||||
return "109.39.0.0/16"
|
||||
case "234", "235": // UK
|
||||
return "212.183.0.0/16"
|
||||
case "515": // Philippines
|
||||
return "112.198.0.0/16"
|
||||
case "454": // Hong Kong
|
||||
return "203.0.0.0/16"
|
||||
case "466", "467": // Taiwan
|
||||
return "210.0.0.0/16"
|
||||
case "525": // Singapore
|
||||
return "202.166.0.0/16"
|
||||
case "440", "441": // Japan
|
||||
return "126.0.0.0/16"
|
||||
case "450": // South Korea
|
||||
return "211.0.0.0/16"
|
||||
case "310", "311", "312", "313", "314", "315", "316": // USA
|
||||
return "198.228.0.0/16"
|
||||
case "302": // Canada
|
||||
return "142.0.0.0/16"
|
||||
case "505": // Australia
|
||||
return "1.120.0.0/16"
|
||||
case "520": // Thailand
|
||||
return "171.96.0.0/16"
|
||||
case "510": // Indonesia
|
||||
return "182.0.0.0/16"
|
||||
case "502": // Malaysia
|
||||
return "115.132.0.0/16"
|
||||
case "208": // France
|
||||
return "194.51.0.0/16"
|
||||
case "214": // Spain
|
||||
return "212.166.0.0/16"
|
||||
case "222": // Italy
|
||||
return "83.224.0.0/16"
|
||||
case "228": // Switzerland
|
||||
return "178.192.0.0/16"
|
||||
case "232": // Austria
|
||||
return "194.138.0.0/16"
|
||||
case "206": // Belgium
|
||||
return "193.190.0.0/16"
|
||||
case "260": // Poland
|
||||
return "83.0.0.0/16"
|
||||
case "268": // Portugal
|
||||
return "194.65.0.0/16"
|
||||
case "272": // Ireland
|
||||
return "193.1.0.0/16"
|
||||
case "238": // Denmark
|
||||
return "193.162.0.0/16"
|
||||
case "240": // Sweden
|
||||
return "194.236.0.0/16"
|
||||
case "242": // Norway
|
||||
return "193.69.0.0/16"
|
||||
case "244": // Finland
|
||||
return "193.64.0.0/16"
|
||||
case "202": // Greece
|
||||
return "194.219.0.0/16"
|
||||
case "216": // Hungary
|
||||
return "195.199.0.0/16"
|
||||
case "230": // Czech Republic
|
||||
return "195.113.0.0/16"
|
||||
case "286": // Turkey
|
||||
return "195.175.0.0/16"
|
||||
case "425": // Israel
|
||||
return "192.114.0.0/16"
|
||||
case "404", "405": // India
|
||||
return "103.0.0.0/16"
|
||||
case "655": // South Africa
|
||||
return "196.0.0.0/16"
|
||||
case "724": // Brazil
|
||||
return "177.0.0.0/16"
|
||||
case "334": // Mexico
|
||||
return "187.188.0.0/16"
|
||||
case "452": // Vietnam
|
||||
return "118.69.0.0/16"
|
||||
case "455": // Macao
|
||||
return "202.175.0.0/16"
|
||||
case "530": // New Zealand
|
||||
return "202.27.0.0/16"
|
||||
case "460": // China
|
||||
return "223.5.5.0/24"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func standardEPDGHostname(mcc, mnc string) string {
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
for len(mnc) < 3 {
|
||||
|
||||
@@ -1,56 +1,75 @@
|
||||
package vowifi
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iccid string
|
||||
imsi string
|
||||
wantMCC string
|
||||
wantMNC string
|
||||
wantAssigned bool
|
||||
}{
|
||||
{name: "XeSIM Lebara route", iccid: "8944160000000000001", imsi: "204047000000001", wantMCC: "234", wantMNC: "15", wantAssigned: true},
|
||||
{name: "CTExcel initial route", iccid: "8944300000000000001", imsi: "234336000000001", wantMCC: "234", wantMNC: "30", wantAssigned: true},
|
||||
{name: "XeSIM ICCID without matching subscription", iccid: "8944160000000000001", imsi: "204041000000001"},
|
||||
{name: "similar ICCID must not match", iccid: "8944100000000000001", imsi: "204047000000001"},
|
||||
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336000000001"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
|
||||
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
|
||||
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
||||
})
|
||||
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
|
||||
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
|
||||
if profile.ID != CarrierProfileStandard || profile.MatchSource != "standard" {
|
||||
t.Fatalf("default profile = %#v", profile)
|
||||
}
|
||||
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
|
||||
t.Fatalf("route ePDG = %q", identity.EPDG)
|
||||
if profile.IKEProposal != IKEProposalModern || !profile.AdvertiseEAPOnly ||
|
||||
profile.IMSIdentityProfile != IMSProfileStandard || profile.IMSRegisterProfile != IMSProfileStandard {
|
||||
t.Fatalf("default profile lost standard capabilities: %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
||||
if !IsATT310280(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"}) {
|
||||
t.Fatal("AT&T 310/280 identity was not recognized")
|
||||
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
||||
// Cricket MVNO on AT&T network
|
||||
cricket := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8901150000000000001", IMSI: "310150000000001",
|
||||
HomeMCC: "310", HomeMNC: "150",
|
||||
})
|
||||
if !strings.Contains(cricket.ID, "cricket") {
|
||||
t.Fatalf("Cricket MVNO profile = %#v", cricket)
|
||||
}
|
||||
for _, identity := range []SIMIdentity{
|
||||
{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "280"},
|
||||
{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "28"},
|
||||
{IMSI: "310280000000001", HomeMCC: "311", HomeMNC: "280"},
|
||||
} {
|
||||
if IsATT310280(identity) {
|
||||
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
|
||||
}
|
||||
|
||||
// Pure Talk MVNO on AT&T network via GID1
|
||||
pureTalk := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410", GID1: "62FFFF",
|
||||
})
|
||||
if !strings.Contains(pureTalk.ID, "pure-talk") {
|
||||
t.Fatalf("Pure Talk MVNO profile = %#v", pureTalk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||
})
|
||||
if !strings.Contains(profile.ID, "giffgaff") || profile.MatchSource != "hplmn+gid1" {
|
||||
t.Fatalf("giffgaff profile = %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileATT(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
|
||||
})
|
||||
if !strings.Contains(profile.ID, "att") {
|
||||
t.Fatalf("AT&T profile = %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
|
||||
if profile.ID != CarrierProfileStandard {
|
||||
t.Fatalf("profile = %q", profile.ID)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ExpirySeconds != 0 {
|
||||
t.Fatalf("standard expiry = %d", profile.IMSRegisterOptions.ExpirySeconds)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ContactFormat != "" {
|
||||
t.Fatalf("standard contact format = %q", profile.IMSRegisterOptions.ContactFormat)
|
||||
}
|
||||
if profile.IMSRegisterOptions.SupportedHeader != nil {
|
||||
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
|
||||
}
|
||||
if profile.AllowSMSWithoutContactConfirmation {
|
||||
t.Fatal("standard profile should require SMS contact confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
const (
|
||||
maxIPCCBytes = 32 << 20
|
||||
maxIPCCFiles = 512
|
||||
maxIPCCPlistBytes = 4 << 20
|
||||
maxIPCCPlistTotalBytes = 64 << 20
|
||||
installedProfileFileMode = 0o600
|
||||
)
|
||||
|
||||
var supportedSIMPLMN = regexp.MustCompile(`^[0-9]{5,6}$`)
|
||||
|
||||
// IPCCImportOptions controls deterministic bundle selection and profile ID
|
||||
// generation. Bundle may be a full archive directory or the final .bundle
|
||||
// name. ProfileID overrides the generated, filesystem-safe ID.
|
||||
type IPCCImportOptions struct {
|
||||
Bundle string
|
||||
ProfileID string
|
||||
}
|
||||
|
||||
// IPCCImportWarning describes a value that was ambiguous, unsafe, or outside
|
||||
// VoCat's portable carrier-profile schema. Such values are reported but never
|
||||
// copied into the installed profile.
|
||||
type IPCCImportWarning struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// IPCCImportResult contains a reviewable carrier-profile document. Document
|
||||
// is complete JSON and can be installed without retaining the Apple archive.
|
||||
type IPCCImportResult struct {
|
||||
SourceFile string `json:"source_file"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
Bundle string `json:"bundle"`
|
||||
CarrierName string `json:"carrier_name"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
Warnings []IPCCImportWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type ipccPlist struct {
|
||||
name string
|
||||
root map[string]any
|
||||
}
|
||||
|
||||
type ipccWarningSet struct {
|
||||
items []IPCCImportWarning
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func (set *ipccWarningSet) add(code, message, plistPath string) {
|
||||
if set.seen == nil {
|
||||
set.seen = make(map[string]struct{})
|
||||
}
|
||||
item := IPCCImportWarning{Code: code, Message: message, Path: plistPath}
|
||||
// Device-family override plists often repeat the same setting. Preserve the
|
||||
// first concrete path while keeping the review output compact.
|
||||
key := code + "\x00" + message
|
||||
if _, duplicate := set.seen[key]; duplicate {
|
||||
return
|
||||
}
|
||||
set.seen[key] = struct{}{}
|
||||
set.items = append(set.items, item)
|
||||
}
|
||||
|
||||
// ImportCarrierIPCC converts a local Apple .ipcc/.zip archive into one
|
||||
// reviewable VoCat carrier profile. It never contacts Apple and never installs the
|
||||
// result. Device-specific and security-weakening values are deliberately
|
||||
// omitted with structured warnings.
|
||||
func ImportCarrierIPCC(filePath string, options IPCCImportOptions) (IPCCImportResult, error) {
|
||||
filePath = strings.TrimSpace(filePath)
|
||||
if filePath == "" {
|
||||
return IPCCImportResult{}, errors.New("IPCC path is empty")
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("stat IPCC %q: %w", filePath, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return IPCCImportResult{}, fmt.Errorf("IPCC %q is not a regular file", filePath)
|
||||
}
|
||||
if info.Size() <= 0 || info.Size() > maxIPCCBytes {
|
||||
return IPCCImportResult{}, fmt.Errorf("IPCC %q size %d is outside 1..%d bytes", filePath, info.Size(), maxIPCCBytes)
|
||||
}
|
||||
encoded, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("read IPCC %q: %w", filePath, err)
|
||||
}
|
||||
archive, err := zip.NewReader(bytes.NewReader(encoded), int64(len(encoded)))
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("open IPCC %q: %w", filePath, err)
|
||||
}
|
||||
if len(archive.File) > maxIPCCFiles {
|
||||
return IPCCImportResult{}, fmt.Errorf("IPCC contains %d files; maximum is %d", len(archive.File), maxIPCCFiles)
|
||||
}
|
||||
|
||||
bundleRoots := carrierBundleRoots(archive.File)
|
||||
bundleRoot, err := selectCarrierBundle(bundleRoots, options.Bundle)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, err
|
||||
}
|
||||
plists, err := readCarrierBundlePlists(archive.File, bundleRoot)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, err
|
||||
}
|
||||
primary := plists[0]
|
||||
warnings := &ipccWarningSet{}
|
||||
carrierName := firstNonempty(
|
||||
plistString(primary.root["CarrierName"]),
|
||||
statusBarCarrierName(primary.root),
|
||||
strings.TrimSuffix(path.Base(bundleRoot), path.Ext(bundleRoot)),
|
||||
)
|
||||
|
||||
matches, plmns, err := importCarrierSelectors(primary.root, plists, warnings)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("import selectors from %s: %w", primary.name, err)
|
||||
}
|
||||
profileID := strings.TrimSpace(options.ProfileID)
|
||||
if profileID == "" {
|
||||
profileID = generatedIPCCProfileID(carrierName, plmns)
|
||||
}
|
||||
if !validInstalledProfileID(profileID) {
|
||||
return IPCCImportResult{}, fmt.Errorf("profile ID %q must match [a-z0-9][a-z0-9._-]{0,63}", profileID)
|
||||
}
|
||||
|
||||
rule := carrierProfileRule{ID: profileID}
|
||||
if len(matches) == 1 {
|
||||
rule.Match = matches[0]
|
||||
} else {
|
||||
rule.MatchAny = matches
|
||||
}
|
||||
importCarrierEPDG(&rule, plists, warnings)
|
||||
importCarrierIKE(&rule, plists, warnings)
|
||||
importCarrierIMS(&rule, plists, warnings)
|
||||
inspectIgnoredCarrierFields(plists, warnings)
|
||||
if !validCarrierProfileRule(rule) {
|
||||
return IPCCImportResult{}, errors.New("converted IPCC profile is not valid")
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(encoded)
|
||||
document := struct {
|
||||
Version int `json:"version"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
}{
|
||||
Version: CarrierProfileSchemaVersion,
|
||||
Metadata: map[string]string{
|
||||
"source": "user-supplied Apple carrier bundle",
|
||||
"source_sha256": hex.EncodeToString(sum[:]),
|
||||
"bundle": bundleRoot,
|
||||
"generated_by": "vocat carrier import-ipcc",
|
||||
},
|
||||
Profiles: []carrierProfileRule{rule},
|
||||
}
|
||||
documentJSON, err := json.MarshalIndent(document, "", " ")
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("encode imported carrier profile: %w", err)
|
||||
}
|
||||
return IPCCImportResult{
|
||||
SourceFile: filepath.Base(filePath),
|
||||
SourceSHA256: hex.EncodeToString(sum[:]),
|
||||
Bundle: bundleRoot,
|
||||
CarrierName: carrierName,
|
||||
ProfileID: profileID,
|
||||
Document: append(documentJSON, '\n'),
|
||||
Warnings: warnings.items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InstallCarrierIPCCResult atomically writes an already-reviewed import result
|
||||
// to dir. Existing files are never replaced; importing an update therefore
|
||||
// requires an explicit operator decision outside this function.
|
||||
func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, error) {
|
||||
if !validInstalledProfileID(result.ProfileID) {
|
||||
return "", fmt.Errorf("invalid profile ID %q", result.ProfileID)
|
||||
}
|
||||
if len(result.Document) == 0 {
|
||||
return "", errors.New("import result has no profile document")
|
||||
}
|
||||
if _, err := loadCarrierProfiles(result.Document); err != nil {
|
||||
return "", fmt.Errorf("validate imported profile: %w", err)
|
||||
}
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return "", errors.New("carrier profile directory is empty")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create carrier profile directory %q: %w", dir, err)
|
||||
}
|
||||
target := filepath.Join(dir, result.ProfileID+".json")
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", fmt.Errorf("carrier profile %q already exists", target)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("stat carrier profile %q: %w", target, err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(dir, "."+result.ProfileID+"-*.tmp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temporary carrier profile: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
removeTemporary := true
|
||||
defer func() {
|
||||
_ = temporary.Close()
|
||||
if removeTemporary {
|
||||
_ = os.Remove(temporaryPath)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(installedProfileFileMode); err != nil {
|
||||
return "", fmt.Errorf("protect temporary carrier profile: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(result.Document); err != nil {
|
||||
return "", fmt.Errorf("write temporary carrier profile: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return "", fmt.Errorf("sync temporary carrier profile: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return "", fmt.Errorf("close temporary carrier profile: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, target); err != nil {
|
||||
return "", fmt.Errorf("install carrier profile %q: %w", target, err)
|
||||
}
|
||||
removeTemporary = false
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// ImportCarrierBundlePlists converts a set of parsed plists for one Apple
|
||||
// carrier bundle into a validated carrierProfileRule.
|
||||
func ImportCarrierBundlePlists(bundleName string, plistData map[string][]byte) (*carrierProfileRule, []IPCCImportWarning, error) {
|
||||
if len(plistData) == 0 {
|
||||
return nil, nil, errors.New("no plist data provided")
|
||||
}
|
||||
var primaryData []byte
|
||||
if data, ok := plistData["carrier.plist"]; ok {
|
||||
primaryData = data
|
||||
} else {
|
||||
for k, v := range plistData {
|
||||
if strings.EqualFold(path.Base(k), "carrier.plist") {
|
||||
primaryData = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(primaryData) == 0 {
|
||||
return nil, nil, fmt.Errorf("bundle %q has no carrier.plist", bundleName)
|
||||
}
|
||||
var primaryRoot map[string]any
|
||||
decoder := plist.NewDecoder(bytes.NewReader(primaryData))
|
||||
if err := decoder.Decode(&primaryRoot); err != nil {
|
||||
return nil, nil, fmt.Errorf("decode carrier.plist: %w", err)
|
||||
}
|
||||
if primaryRoot == nil {
|
||||
return nil, nil, errors.New("carrier.plist root is not a dictionary")
|
||||
}
|
||||
plists := []ipccPlist{{name: "carrier.plist", root: primaryRoot}}
|
||||
|
||||
var overrideNames []string
|
||||
for k := range plistData {
|
||||
base := path.Base(k)
|
||||
if strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist") {
|
||||
overrideNames = append(overrideNames, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(overrideNames)
|
||||
for _, k := range overrideNames {
|
||||
var overrideRoot map[string]any
|
||||
dec := plist.NewDecoder(bytes.NewReader(plistData[k]))
|
||||
if err := dec.Decode(&overrideRoot); err == nil && overrideRoot != nil {
|
||||
plists = append(plists, ipccPlist{name: k, root: overrideRoot})
|
||||
}
|
||||
}
|
||||
|
||||
warnings := &ipccWarningSet{}
|
||||
carrierName := firstNonempty(
|
||||
plistString(primaryRoot["CarrierName"]),
|
||||
statusBarCarrierName(primaryRoot),
|
||||
strings.TrimSuffix(bundleName, path.Ext(bundleName)),
|
||||
)
|
||||
matches, plmns, err := importCarrierSelectors(primaryRoot, plists, warnings)
|
||||
if err != nil {
|
||||
return nil, warnings.items, fmt.Errorf("import selectors: %w", err)
|
||||
}
|
||||
profileID := generatedIPCCProfileID(carrierName, plmns)
|
||||
if !validInstalledProfileID(profileID) {
|
||||
return nil, warnings.items, fmt.Errorf("invalid profile ID %q", profileID)
|
||||
}
|
||||
rule := carrierProfileRule{ID: profileID}
|
||||
if len(matches) == 1 {
|
||||
rule.Match = matches[0]
|
||||
} else {
|
||||
rule.MatchAny = matches
|
||||
}
|
||||
importCarrierEPDG(&rule, plists, warnings)
|
||||
importCarrierIKE(&rule, plists, warnings)
|
||||
importCarrierIMS(&rule, plists, warnings)
|
||||
inspectIgnoredCarrierFields(plists, warnings)
|
||||
if !validCarrierProfileRule(rule) {
|
||||
return nil, warnings.items, errors.New("converted profile is not valid")
|
||||
}
|
||||
return &rule, warnings.items, nil
|
||||
}
|
||||
|
||||
func carrierBundleRoots(files []*zip.File) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, file := range files {
|
||||
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
||||
if strings.Contains(strings.ToLower(name), "/signatures/") ||
|
||||
!strings.EqualFold(path.Base(name), "carrier.plist") {
|
||||
continue
|
||||
}
|
||||
root := path.Dir(name)
|
||||
if root == "." || root == "/" {
|
||||
continue
|
||||
}
|
||||
seen[root] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for root := range seen {
|
||||
result = append(result, root)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func selectCarrierBundle(roots []string, wanted string) (string, error) {
|
||||
if len(roots) == 0 {
|
||||
return "", errors.New("IPCC contains no carrier.plist bundle")
|
||||
}
|
||||
wanted = strings.TrimSpace(strings.ReplaceAll(wanted, "\\", "/"))
|
||||
if wanted != "" {
|
||||
for _, root := range roots {
|
||||
base := path.Base(root)
|
||||
if strings.EqualFold(root, wanted) || strings.EqualFold(base, wanted) ||
|
||||
strings.EqualFold(strings.TrimSuffix(base, path.Ext(base)), strings.TrimSuffix(wanted, path.Ext(wanted))) {
|
||||
return root, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("carrier bundle %q not found; choices: %s", wanted, strings.Join(roots, ", "))
|
||||
}
|
||||
if len(roots) != 1 {
|
||||
return "", fmt.Errorf("IPCC contains multiple carrier bundles; select one with --bundle: %s", strings.Join(roots, ", "))
|
||||
}
|
||||
return roots[0], nil
|
||||
}
|
||||
|
||||
func readCarrierBundlePlists(files []*zip.File, root string) ([]ipccPlist, error) {
|
||||
var primary *zip.File
|
||||
overrides := make([]*zip.File, 0)
|
||||
rootPrefix := strings.TrimSuffix(root, "/") + "/"
|
||||
for _, file := range files {
|
||||
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
||||
if !strings.HasPrefix(name, rootPrefix) || strings.Contains(strings.ToLower(name), "/signatures/") {
|
||||
continue
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case strings.EqualFold(name, rootPrefix+"carrier.plist"):
|
||||
primary = file
|
||||
case strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist"):
|
||||
overrides = append(overrides, file)
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return nil, fmt.Errorf("bundle %q has no carrier.plist", root)
|
||||
}
|
||||
sort.Slice(overrides, func(i, j int) bool { return overrides[i].Name < overrides[j].Name })
|
||||
selected := append([]*zip.File{primary}, overrides...)
|
||||
result := make([]ipccPlist, 0, len(selected))
|
||||
var total uint64
|
||||
for _, file := range selected {
|
||||
if file.UncompressedSize64 > maxIPCCPlistBytes {
|
||||
return nil, fmt.Errorf("plist %q exceeds %d bytes", file.Name, maxIPCCPlistBytes)
|
||||
}
|
||||
total += file.UncompressedSize64
|
||||
if total > maxIPCCPlistTotalBytes {
|
||||
return nil, fmt.Errorf("selected plists exceed %d uncompressed bytes", maxIPCCPlistTotalBytes)
|
||||
}
|
||||
root, err := decodeIPCCPlist(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode plist %q: %w", file.Name, err)
|
||||
}
|
||||
result = append(result, ipccPlist{name: file.Name, root: root})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeIPCCPlist(file *zip.File) (map[string]any, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
encoded, err := io.ReadAll(io.LimitReader(reader, maxIPCCPlistBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(encoded) > maxIPCCPlistBytes {
|
||||
return nil, fmt.Errorf("plist exceeds %d bytes", maxIPCCPlistBytes)
|
||||
}
|
||||
decoder := plist.NewDecoder(bytes.NewReader(encoded))
|
||||
var root map[string]any
|
||||
if err := decoder.Decode(&root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if root == nil {
|
||||
return nil, errors.New("plist root is not a dictionary")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func importCarrierSelectors(primary map[string]any, plists []ipccPlist, warnings *ipccWarningSet) ([]carrierProfileMatch, []string, error) {
|
||||
supportedSIMs := plistStrings(primary["SupportedSIMs"])
|
||||
supportedPLMNs := normalizedPLMNs(plistStrings(primary["SupportedPLMNs"]))
|
||||
plainPLMNs := make([]string, 0)
|
||||
qualified := make([]carrierProfileMatch, 0)
|
||||
for _, raw := range supportedSIMs {
|
||||
match, constrained, valid := parseAppleSupportedSIM(raw, warnings)
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
if constrained {
|
||||
qualified = append(qualified, match)
|
||||
} else {
|
||||
plainPLMNs = append(plainPLMNs, match.HomePLMNs...)
|
||||
}
|
||||
}
|
||||
|
||||
allPLMNs := normalizeIPCCStringList(append(append([]string(nil), plainPLMNs...), supportedPLMNs...), false)
|
||||
matches := qualified
|
||||
if len(matches) == 0 {
|
||||
if len(allPLMNs) == 0 {
|
||||
return nil, nil, errors.New("no supported MCC/MNC selector was found")
|
||||
}
|
||||
match := carrierProfileMatch{HomePLMNs: allPLMNs}
|
||||
iccidPrefixes := collectMatchingICCIDPrefixes(plists)
|
||||
if len(iccidPrefixes) > 0 {
|
||||
match.ICCIDPrefixes = iccidPrefixes
|
||||
warnings.add(
|
||||
"remote_provisioning_iccid_selector",
|
||||
"MatchingICCIDPrefixes was used only because the bundle has no GID/SPN selector; verify that it identifies subscriptions rather than only eSIM provisioning eligibility",
|
||||
"RemoteCardProvisioningSettings.MatchingICCIDPrefixes",
|
||||
)
|
||||
} else {
|
||||
warnings.add(
|
||||
"broad_plmn_selector",
|
||||
"the generated rule matches a whole home PLMN because the bundle exposes no GID, SPN, or ICCID discriminator",
|
||||
"SupportedSIMs",
|
||||
)
|
||||
}
|
||||
matches = []carrierProfileMatch{match}
|
||||
}
|
||||
matches = deduplicateCarrierMatches(matches)
|
||||
if len(matches) == 0 {
|
||||
return nil, nil, errors.New("all SupportedSIMs selectors were unsupported")
|
||||
}
|
||||
if len(allPLMNs) == 0 {
|
||||
for _, match := range matches {
|
||||
allPLMNs = append(allPLMNs, match.HomePLMNs...)
|
||||
}
|
||||
allPLMNs = normalizeIPCCStringList(allPLMNs, false)
|
||||
}
|
||||
return matches, allPLMNs, nil
|
||||
}
|
||||
|
||||
func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfileMatch, bool, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parts := strings.Split(raw, "_")
|
||||
if len(parts) == 0 || !supportedSIMPLMN.MatchString(parts[0]) || canonicalPLMNValue(parts[0]) == "" {
|
||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs value "+strconv.Quote(raw), "SupportedSIMs")
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
match := carrierProfileMatch{HomePLMNs: []string{parts[0]}}
|
||||
for _, qualifier := range parts[1:] {
|
||||
name, value, found := strings.Cut(qualifier, "-")
|
||||
value = strings.TrimSpace(value)
|
||||
if !found || value == "" {
|
||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(qualifier), "SupportedSIMs")
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "GID1":
|
||||
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||
match.GID1Prefixes = append(match.GID1Prefixes, trimmed)
|
||||
}
|
||||
case "GID2":
|
||||
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||
match.GID2Prefixes = append(match.GID2Prefixes, trimmed)
|
||||
}
|
||||
case "ICCID":
|
||||
if trimmed := strings.TrimRight(value, "Ff"); trimmed != "" {
|
||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, trimmed)
|
||||
}
|
||||
case "SPN":
|
||||
match.SPNs = append(match.SPNs, value)
|
||||
default:
|
||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(name), "SupportedSIMs")
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
}
|
||||
constrained := len(match.GID1Prefixes) > 0 || len(match.GID2Prefixes) > 0 || len(match.ICCIDPrefixes) > 0 || len(match.SPNs) > 0
|
||||
return match, constrained, true
|
||||
}
|
||||
|
||||
func trimAppleHexMask(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
return strings.TrimRight(value, "F")
|
||||
}
|
||||
|
||||
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
||||
values := make([]string, 0)
|
||||
for _, document := range plists {
|
||||
walkPlist(document.root, nil, func(path []string, value any) {
|
||||
if len(path) == 0 || !strings.EqualFold(path[len(path)-1], "MatchingICCIDPrefixes") {
|
||||
return
|
||||
}
|
||||
for _, prefix := range plistStrings(value) {
|
||||
prefix = strings.TrimRight(strings.TrimSpace(prefix), "Ff")
|
||||
if len(prefix) >= 5 && decimalString(prefix) {
|
||||
values = append(values, prefix)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return normalizeIPCCStringList(values, false)
|
||||
}
|
||||
|
||||
func importCarrierEPDG(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
addresses := make(map[string][]string)
|
||||
for _, document := range plists {
|
||||
for _, ike := range dictionariesForKey(document.root, "IKE") {
|
||||
address := strings.ToLower(strings.TrimSuffix(plistString(ike.value["RemoteAddress"]), "."))
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
if !validEPDGHostname(address) {
|
||||
warnings.add("unsupported_epdg_address", "ignored non-ePDG IKE RemoteAddress "+strconv.Quote(address), document.name+":"+strings.Join(ike.path, "."))
|
||||
continue
|
||||
}
|
||||
addresses[address] = append(addresses[address], document.name)
|
||||
}
|
||||
}
|
||||
keys := sortedMapKeys(addresses)
|
||||
switch len(keys) {
|
||||
case 0:
|
||||
warnings.add("epdg_not_explicit", "no unambiguous ePDG RemoteAddress was found; VoCat will derive the standard 3GPP hostname from the matched PLMN", "TechSettings.IKE.RemoteAddress")
|
||||
case 1:
|
||||
rule.EPDG.Hostname = keys[0]
|
||||
default:
|
||||
warnings.add("conflicting_epdg", "device override plists disagree on ePDG RemoteAddress; no address was imported: "+strings.Join(keys, ", "), "TechSettings.IKE.RemoteAddress")
|
||||
}
|
||||
}
|
||||
|
||||
func importCarrierIKE(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
groups := make(map[int]struct{})
|
||||
eapMethods := make(map[string]struct{})
|
||||
for _, document := range plists {
|
||||
for _, located := range dictionariesForKey(document.root, "IKE") {
|
||||
ike := located.value
|
||||
for _, proposal := range plistDictionaries(ike["Proposals"]) {
|
||||
if group, ok := plistInt(proposal["DHGroup"]); ok {
|
||||
groups[group] = struct{}{}
|
||||
}
|
||||
if method := strings.ToUpper(plistString(proposal["EAPMethod"])); method != "" {
|
||||
eapMethods[method] = struct{}{}
|
||||
}
|
||||
}
|
||||
if validate, ok := plistBool(ike["ValidateRemoteCertificate"]); ok && !validate {
|
||||
warnings.add("remote_certificate_bypass_ignored", "ValidateRemoteCertificate=false was not imported", document.name+":"+strings.Join(located.path, ".")+".ValidateRemoteCertificate")
|
||||
}
|
||||
if enabled, ok := plistBool(ike["DeadPeerDetectionEnabled"]); ok {
|
||||
if !enabled {
|
||||
warnings.add("disabled_dpd_ignored", "Apple disables DPD for this device family; VoCat keeps its safe liveness defaults", document.name+":"+strings.Join(located.path, ".")+".DeadPeerDetectionEnabled")
|
||||
} else if _, hasInterval := ike["DeadPeerDetectionInterval"]; hasInterval {
|
||||
warnings.add("dpd_override_ignored", "device-specific DPD timing was not imported; VoCat keeps its runtime defaults", document.name+":"+strings.Join(located.path, "."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
unknown := make([]string, 0)
|
||||
_, hasModern := groups[14]
|
||||
_, hasLegacy := groups[2]
|
||||
for group := range groups {
|
||||
if group != 2 && group != 14 {
|
||||
unknown = append(unknown, strconv.Itoa(group))
|
||||
}
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
switch {
|
||||
case len(unknown) > 0:
|
||||
warnings.add("unsupported_ike_group", "unsupported IKE DH group(s) were not imported: "+strings.Join(unknown, ", "), "TechSettings.IKE.Proposals")
|
||||
case hasModern:
|
||||
rule.IKE.Proposal = IKEProposalModern
|
||||
case hasLegacy:
|
||||
rule.IKE.Proposal = IKEProposalLegacy
|
||||
}
|
||||
}
|
||||
for method := range eapMethods {
|
||||
if method != "EAP-AKA" && method != "EAP-AKA'" {
|
||||
warnings.add("unsupported_eap_method", "VoCat does not import Apple EAP method "+strconv.Quote(method), "TechSettings.IKE.Proposals.EAPMethod")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
useIPSec := false
|
||||
for _, document := range plists {
|
||||
for _, signaling := range dictionariesForKey(document.root, "Signaling") {
|
||||
if value, ok := plistBool(signaling.value["UseIPSec"]); ok {
|
||||
if value {
|
||||
useIPSec = true
|
||||
} else {
|
||||
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if useIPSec {
|
||||
// Apple does not describe the negotiated ESP algorithm in a portable
|
||||
// field. Keep VoCat's safe AES-CBC default while recording the intent.
|
||||
rule.IMS.IPSecEncryption = "aes-cbc"
|
||||
}
|
||||
}
|
||||
|
||||
func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
for _, document := range plists {
|
||||
walkPlist(document.root, nil, func(keyPath []string, value any) {
|
||||
if len(keyPath) == 0 {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(keyPath[len(keyPath)-1])
|
||||
fullPath := document.name + ":" + strings.Join(keyPath, ".")
|
||||
switch {
|
||||
case key == "enablewificallingwithoutentitlement":
|
||||
if enabled, ok := plistBool(value); ok && enabled {
|
||||
warnings.add("entitlement_bypass_ignored", "Wi-Fi Calling entitlement bypass was not imported", fullPath)
|
||||
}
|
||||
case key == "apns":
|
||||
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
||||
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
||||
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
||||
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
||||
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type locatedDictionary struct {
|
||||
path []string
|
||||
value map[string]any
|
||||
}
|
||||
|
||||
func dictionariesForKey(root map[string]any, wanted string) []locatedDictionary {
|
||||
result := make([]locatedDictionary, 0)
|
||||
walkPlist(root, nil, func(keyPath []string, value any) {
|
||||
if len(keyPath) == 0 || !strings.EqualFold(keyPath[len(keyPath)-1], wanted) {
|
||||
return
|
||||
}
|
||||
if dictionary, ok := value.(map[string]any); ok {
|
||||
result = append(result, locatedDictionary{path: append([]string(nil), keyPath...), value: dictionary})
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func walkPlist(value any, keyPath []string, visit func([]string, any)) {
|
||||
visit(keyPath, value)
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
walkPlist(typed[key], appendPath(keyPath, key), visit)
|
||||
}
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
walkPlist(item, appendPath(keyPath, strconv.Itoa(index)), visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendPath(base []string, item string) []string {
|
||||
result := make([]string, len(base), len(base)+1)
|
||||
copy(result, base)
|
||||
return append(result, item)
|
||||
}
|
||||
|
||||
func plistStrings(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
return []string{strings.TrimSpace(typed)}
|
||||
}
|
||||
case []any:
|
||||
result := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if value := plistString(item); value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []string:
|
||||
return normalizeIPCCStringList(typed, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeIPCCStringList(values []string, lower bool) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if lower {
|
||||
value = strings.ToLower(value)
|
||||
}
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func plistDictionaries(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return []map[string]any{typed}
|
||||
case []any:
|
||||
result := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if dictionary, ok := item.(map[string]any); ok {
|
||||
result = append(result, dictionary)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func plistString(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func plistBool(value any) (bool, bool) {
|
||||
result, ok := value.(bool)
|
||||
return result, ok
|
||||
}
|
||||
|
||||
func plistInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), int64(int(typed)) == typed
|
||||
case uint64:
|
||||
return int(typed), uint64(int(typed)) == typed
|
||||
case float64:
|
||||
return int(typed), float64(int(typed)) == typed
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedPLMNs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if supportedSIMPLMN.MatchString(value) && canonicalPLMNValue(value) != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return normalizeIPCCStringList(result, false)
|
||||
}
|
||||
|
||||
func deduplicateCarrierMatches(matches []carrierProfileMatch) []carrierProfileMatch {
|
||||
result := make([]carrierProfileMatch, 0, len(matches))
|
||||
seen := make(map[string]struct{})
|
||||
for _, match := range matches {
|
||||
encoded, _ := json.Marshal(match)
|
||||
key := string(encoded)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, match)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func statusBarCarrierName(root map[string]any) string {
|
||||
for _, item := range plistDictionaries(root["StatusBarImages"]) {
|
||||
if name := firstNonempty(plistString(item["CarrierName"]), plistString(item["StatusBarCarrierName"])); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generatedIPCCProfileID(carrierName string, plmns []string) string {
|
||||
base := slugCarrierProfileID(carrierName)
|
||||
if base == "" {
|
||||
base = "carrier"
|
||||
}
|
||||
if len(plmns) > 0 {
|
||||
base += "-" + plmns[0]
|
||||
}
|
||||
base = "ipcc-" + base
|
||||
if len(base) > 64 {
|
||||
base = strings.TrimRight(base[:64], "-._")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func slugCarrierProfileID(value string) string {
|
||||
var result strings.Builder
|
||||
separator := false
|
||||
for _, item := range strings.ToLower(strings.TrimSpace(value)) {
|
||||
switch {
|
||||
case item >= 'a' && item <= 'z', item >= '0' && item <= '9':
|
||||
if separator && result.Len() > 0 {
|
||||
result.WriteByte('-')
|
||||
}
|
||||
result.WriteRune(item)
|
||||
separator = false
|
||||
case unicode.IsSpace(item), item == '-', item == '_', item == '.':
|
||||
separator = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(result.String(), "-")
|
||||
}
|
||||
|
||||
func validInstalledProfileID(value string) bool {
|
||||
if len(value) < 1 || len(value) > 64 || !asciiLowerOrDigit(rune(value[0])) {
|
||||
return false
|
||||
}
|
||||
for _, item := range value {
|
||||
if asciiLowerOrDigit(item) || item == '-' || item == '_' || item == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func asciiLowerOrDigit(item rune) bool {
|
||||
return item >= 'a' && item <= 'z' || item >= '0' && item <= '9'
|
||||
}
|
||||
|
||||
func validEPDGHostname(value string) bool {
|
||||
if len(value) < 4 || len(value) > 253 || !strings.Contains(strings.ToLower(value), "epdg") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(value, ".") {
|
||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, item := range label {
|
||||
if item >= 'a' && item <= 'z' || item >= '0' && item <= '9' || item == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func consensusPositiveInt(values []int) (int, bool) {
|
||||
if len(values) == 0 || values[0] <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
for _, value := range values[1:] {
|
||||
if value != values[0] {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
func sortedMapKeys[T any](values map[string]T) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
result = append(result, key)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNonempty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
type testIPCCPlist struct {
|
||||
value map[string]any
|
||||
format int
|
||||
}
|
||||
|
||||
func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||
"Payload/O2_Giffgaff_UK.bundle/carrier.plist": {
|
||||
format: plist.XMLFormat,
|
||||
value: map[string]any{
|
||||
"CarrierName": "giffgaff",
|
||||
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
||||
"SupportedPLMNs": []any{"23410"},
|
||||
"apns": []any{map[string]any{"apn": "giffgaff.com"}},
|
||||
},
|
||||
},
|
||||
"Payload/O2_Giffgaff_UK.bundle/overrides_D1.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{
|
||||
"TechSettings": map[string]any{
|
||||
"IKE": map[string]any{
|
||||
"RemoteAddress": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org",
|
||||
"ValidateRemoteCertificate": false,
|
||||
"DeadPeerDetectionEnabled": false,
|
||||
"Proposals": []any{map[string]any{
|
||||
"DHGroup": 14, "EAPMethod": "EAP-AKA",
|
||||
}},
|
||||
},
|
||||
},
|
||||
"IMSConfig": map[string]any{
|
||||
"EnableWiFiCallingWithoutEntitlement": true,
|
||||
"Signaling": map[string]any{"UseIPSec": true},
|
||||
"Media": map[string]any{"SupportPCMA": false},
|
||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CarrierName != "giffgaff" || result.ProfileID != "ipcc-giffgaff-23410" || result.SourceSHA256 == "" {
|
||||
t.Fatalf("import metadata = %#v", result)
|
||||
}
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(result.Document, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document.Version != CarrierProfileSchemaVersion || len(document.Profiles) != 1 {
|
||||
t.Fatalf("document = %#v", document)
|
||||
}
|
||||
rule := document.Profiles[0]
|
||||
if rule.Match.HomePLMNs[0] != "23410" || rule.Match.GID1Prefixes[0] != "508" {
|
||||
t.Fatalf("converted selector = %#v", rule.Match)
|
||||
}
|
||||
if rule.EPDG.Hostname != "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org" || rule.IKE.Proposal != IKEProposalModern {
|
||||
t.Fatalf("converted IKE profile = %#v", rule)
|
||||
}
|
||||
if rule.IMS.IPSecEncryption != "aes-cbc" {
|
||||
t.Fatalf("converted IMS profile = %#v", rule.IMS)
|
||||
}
|
||||
for _, code := range []string{
|
||||
"remote_certificate_bypass_ignored",
|
||||
"disabled_dpd_ignored",
|
||||
"entitlement_bypass_ignored",
|
||||
"apn_settings_ignored",
|
||||
"device_media_overrides_ignored",
|
||||
"emergency_settings_ignored",
|
||||
} {
|
||||
if !hasIPCCWarning(result.Warnings, code) {
|
||||
t.Errorf("missing warning %q: %#v", code, result.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCarrierIPCCRejectsAmbiguousBundleAndConflictingEPDG(t *testing.T) {
|
||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||
"Payload/One.bundle/carrier.plist": {
|
||||
format: plist.XMLFormat,
|
||||
value: map[string]any{"CarrierName": "One", "SupportedSIMs": []any{"99901"}},
|
||||
},
|
||||
"Payload/One.bundle/overrides_A.plist": {
|
||||
format: plist.XMLFormat,
|
||||
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.one.example"}}},
|
||||
},
|
||||
"Payload/One.bundle/overrides_B.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.two.example"}}},
|
||||
},
|
||||
"Payload/Two.bundle/carrier.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{"CarrierName": "Two", "SupportedSIMs": []any{"99902"}},
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{}); err == nil {
|
||||
t.Fatal("multi-bundle IPCC imported without --bundle")
|
||||
}
|
||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{Bundle: "One"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(result.Document, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document.Profiles[0].EPDG.Hostname != "" || !hasIPCCWarning(result.Warnings, "conflicting_epdg") {
|
||||
t.Fatalf("conflicting ePDG was not quarantined: %#v, %#v", document.Profiles[0], result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCarrierIPCCResultLoadsExternalProfileAtEqualSpecificity(t *testing.T) {
|
||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||
"Payload/Test.bundle/carrier.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{
|
||||
"CarrierName": "Installed Test",
|
||||
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
||||
"SupportedPLMNs": []any{"23410"},
|
||||
},
|
||||
},
|
||||
})
|
||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{ProfileID: "installed-giffgaff-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
emptyDir := t.TempDir()
|
||||
t.Cleanup(func() {
|
||||
if err := LoadCarrierProfileDirectory(emptyDir); err != nil {
|
||||
t.Errorf("clear external profiles: %v", err)
|
||||
}
|
||||
})
|
||||
target, err := InstallCarrierIPCCResult(result, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(target) != "installed-giffgaff-test.json" {
|
||||
t.Fatalf("installed path = %q", target)
|
||||
}
|
||||
if _, err := InstallCarrierIPCCResult(result, dir); err == nil {
|
||||
t.Fatal("second install overwrote an existing profile")
|
||||
}
|
||||
if err := LoadCarrierProfileDirectory(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"})
|
||||
if profile.ID != "installed-giffgaff-test" {
|
||||
t.Fatalf("installed equal-specificity profile did not override builtin: %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestIPCC(t *testing.T, files map[string]testIPCCPlist) string {
|
||||
t.Helper()
|
||||
archivePath := filepath.Join(t.TempDir(), "carrier.ipcc")
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(file)
|
||||
for name, item := range files {
|
||||
var encoded bytes.Buffer
|
||||
if err := plist.NewEncoderForFormat(&encoded, item.format).Encode(item.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := archive.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return archivePath
|
||||
}
|
||||
|
||||
func hasIPCCWarning(warnings []IPCCImportWarning, code string) bool {
|
||||
for _, warning := range warnings {
|
||||
if warning.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user