mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 23:33:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54288e5657 | ||
|
|
76af0784e1 | ||
|
|
06ea65558c | ||
|
|
d26937f9eb | ||
|
|
688e1e8311 | ||
|
|
f697c418a5 | ||
|
|
8d06231494 | ||
|
|
ee22576124 | ||
|
|
4f3f37ba7c | ||
|
|
53345d2915 | ||
|
|
0cba13634a | ||
|
|
b8df7f43f8 | ||
|
|
497cd24c8d | ||
|
|
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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -344,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)
|
||||
}
|
||||
+3
-2
@@ -22,8 +22,9 @@ Usage:
|
||||
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 safely wake a factory-ID
|
||||
DJI/Baiwang 2ca3:4006 QMI interface without changing NV.
|
||||
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
|
||||
|
||||
+13
-10
@@ -33,14 +33,17 @@ type doctorReport struct {
|
||||
}
|
||||
|
||||
type djiQMIRepairResult struct {
|
||||
USBName string `json:"usb_name"`
|
||||
Interface string `json:"interface"`
|
||||
USBDevice string `json:"usb_device"`
|
||||
OriginalDriver string `json:"original_driver,omitempty"`
|
||||
ControlDevice string `json:"control_device"`
|
||||
NetworkInterface string `json:"network_interface,omitempty"`
|
||||
QMIProbe string `json:"qmi_probe"`
|
||||
Attempts int `json:"attempts"`
|
||||
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 {
|
||||
@@ -49,7 +52,7 @@ func runDoctor(args []string) error {
|
||||
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
|
||||
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
|
||||
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password")
|
||||
repairDJI := flags.Bool("repair-dji-qmi", false, "rebind DJI 2ca3:4006 interface 4 to qmi_wwan and assert DTR (Linux/root only; no NV write)")
|
||||
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 {
|
||||
@@ -79,7 +82,7 @@ func runDoctor(args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair DJI QMI binding: %w", err)
|
||||
}
|
||||
add("dji_qmi_repair", "passed", "dji_qmi_dtr_asserted", "DJI interface 4 was bound to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result)
|
||||
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)
|
||||
|
||||
+194
-37
@@ -19,9 +19,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
djiVendorID = "2ca3"
|
||||
djiProductID = "4006"
|
||||
djiQMIIndex = 4
|
||||
djiVendorID = "2ca3"
|
||||
djiProductID = "4006"
|
||||
djiFirstSerialIndex = 0
|
||||
djiLastSerialIndex = 3
|
||||
djiATIndex = 2
|
||||
djiQMIIndex = 4
|
||||
)
|
||||
|
||||
type usbControlTransfer struct {
|
||||
@@ -35,8 +38,12 @@ type usbControlTransfer struct {
|
||||
}
|
||||
|
||||
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")
|
||||
return repairDJIQMIAt(attemptContext, "/sys", "/dev", qmicli)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,7 +75,7 @@ func retryDJIQMI(
|
||||
return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err)
|
||||
}
|
||||
|
||||
func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMIRepairResult, returnErr error) {
|
||||
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 {
|
||||
@@ -105,20 +112,36 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
|
||||
}
|
||||
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)
|
||||
}
|
||||
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||
if _, err := os.Stat(filepath.Join(driversRoot, "qmi_wwan")); err != nil {
|
||||
modprobe, lookErr := exec.LookPath("modprobe")
|
||||
if lookErr != nil {
|
||||
return result, errors.New("qmi_wwan is not loaded and modprobe is unavailable")
|
||||
}
|
||||
if output, loadErr := exec.CommandContext(ctx, modprobe, "qmi_wwan").CombinedOutput(); loadErr != nil {
|
||||
return result, fmt.Errorf("load qmi_wwan: %w: %s", loadErr, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
interfaceDetached := false
|
||||
restoreOriginal := func() {
|
||||
@@ -128,7 +151,10 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
|
||||
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
||||
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
|
||||
}
|
||||
if result.OriginalDriver != "" {
|
||||
switch result.OriginalDriver {
|
||||
case "qmi_wwan":
|
||||
_ = bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface)
|
||||
case "option":
|
||||
_ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface)
|
||||
}
|
||||
}
|
||||
@@ -147,20 +173,8 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
|
||||
return result, err
|
||||
}
|
||||
|
||||
bindPath := filepath.Join(driversRoot, "qmi_wwan", "bind")
|
||||
if err := writeSysfs(bindPath, result.Interface); err != nil {
|
||||
newIDErr := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "new_id"), djiVendorID+" "+djiProductID)
|
||||
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
|
||||
return result, fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
|
||||
}
|
||||
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
|
||||
if retryErr := writeSysfs(bindPath, result.Interface); retryErr != nil {
|
||||
return result, fmt.Errorf("bind qmi_wwan to %s: %w", result.Interface, retryErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
|
||||
return result, fmt.Errorf("interface %s driver is %q after qmi_wwan bind", result.Interface, driver)
|
||||
if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
@@ -178,25 +192,168 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
|
||||
}
|
||||
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)
|
||||
qmicli, err := exec.LookPath("qmicli")
|
||||
if err != nil {
|
||||
return result, errors.New("qmicli is required to verify DJI QMI readiness after DTR repair")
|
||||
}
|
||||
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
|
||||
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
||||
probeContextErr := probeContext.Err()
|
||||
cancelProbe()
|
||||
result.QMIProbe = strings.TrimSpace(string(output))
|
||||
if probeErr != nil {
|
||||
if probeContext.Err() != nil {
|
||||
probeErr = errors.Join(probeErr, probeContext.Err())
|
||||
if probeContextErr != nil {
|
||||
probeErr = errors.Join(probeErr, probeContextErr)
|
||||
}
|
||||
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
||||
}
|
||||
interfaceDetached = false
|
||||
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 {
|
||||
|
||||
@@ -5,8 +5,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
@@ -49,6 +51,75 @@ func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+137
-8
@@ -41,7 +41,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
|
||||
logs := loghub.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 2000)
|
||||
logger := slog.New(logs)
|
||||
|
||||
args := os.Args[1:]
|
||||
@@ -206,7 +206,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}
|
||||
|
||||
cardReaders := pcsc.New()
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
|
||||
deviceLogger := logger.With("category", "hardware")
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: deviceLogger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device manager: %w", err)
|
||||
}
|
||||
@@ -227,7 +228,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}()
|
||||
pollContext, cancelPolling := context.WithCancel(context.Background())
|
||||
defer cancelPolling()
|
||||
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
||||
go pollDeviceSnapshots(pollContext, deviceLogger, database, deviceManager)
|
||||
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
||||
go collectCellularTraffic(pollContext, logger, database)
|
||||
go persistLogsToStore(pollContext, logger, logs, database)
|
||||
@@ -237,12 +238,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)
|
||||
@@ -276,10 +285,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 {
|
||||
@@ -575,6 +598,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,
|
||||
@@ -617,7 +641,7 @@ func configureVoWiFiRuntime(
|
||||
Devices: mapper,
|
||||
}
|
||||
manager := vowifiruntime.New(vowifiruntime.Options{
|
||||
Logger: logger,
|
||||
Logger: logger.With("category", "vowifi"),
|
||||
OnState: projector.Save,
|
||||
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
|
||||
deviceConfig, err := database.Device(factoryContext, deviceID)
|
||||
@@ -630,7 +654,7 @@ func configureVoWiFiRuntime(
|
||||
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
||||
adapter = nativeQMIAdapter
|
||||
}
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger, onIncomingCall)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -660,7 +684,18 @@ func configureVoWiFiRuntime(
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
|
||||
requestEnable := func() error {
|
||||
_, requestErr := manager.RequestEnabled(deviceConfig.ID, true)
|
||||
return requestErr
|
||||
}
|
||||
if err := requestVoWiFiStartup(
|
||||
ctx,
|
||||
logger,
|
||||
deviceConfig.DeviceType,
|
||||
deviceConfig.ID,
|
||||
wifi410VoWiFiStartupDelay,
|
||||
requestEnable,
|
||||
); err != nil {
|
||||
_ = manager.Close(context.Background())
|
||||
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
|
||||
}
|
||||
@@ -672,8 +707,55 @@ func configureVoWiFiRuntime(
|
||||
const (
|
||||
vowifiStartupRadioAttempts = 3
|
||||
vowifiStartupRadioDelay = time.Second
|
||||
wifi410VoWiFiStartupDelay = 80 * time.Second
|
||||
)
|
||||
|
||||
// requestVoWiFiStartup delays only the persisted startup policy for OpenStick
|
||||
// 410 devices. Their Qualcomm UIM and Vodafone ePDG path need a short quiet
|
||||
// period after a cold boot; user-triggered reconnects and every other device
|
||||
// type continue to execute immediately.
|
||||
func requestVoWiFiStartup(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
deviceType string,
|
||||
deviceID string,
|
||||
delay time.Duration,
|
||||
request func() error,
|
||||
) error {
|
||||
if deviceType != store.DeviceTypeWiFi410 || delay <= 0 {
|
||||
return request()
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
logger.Info(
|
||||
"OpenStick 410 VoWiFi startup delayed",
|
||||
"device_id", deviceID,
|
||||
"delay", delay,
|
||||
)
|
||||
go func() {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if err := request(); err != nil {
|
||||
logger.Warn(
|
||||
"OpenStick 410 delayed VoWiFi startup failed",
|
||||
"device_id", deviceID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldDelayWiFi410VoWiFi(deviceType string, now, notBefore time.Time) bool {
|
||||
return deviceType == store.DeviceTypeWiFi410 && now.Before(notBefore)
|
||||
}
|
||||
|
||||
type flightModeSetter interface {
|
||||
SetFlight(context.Context, string, bool) (device.FlightResult, error)
|
||||
}
|
||||
@@ -730,24 +812,27 @@ func newVoWiFiOrchestrator(
|
||||
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"
|
||||
}
|
||||
vowifiLogger := logger.With("category", "vowifi", "device_id", deviceConfig.ID)
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||
APN: apn, Logger: vowifiLogger, AutoProposalFallback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||
}
|
||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||
Logger: logger,
|
||||
Logger: vowifiLogger,
|
||||
// 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",
|
||||
@@ -759,6 +844,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 {
|
||||
@@ -823,6 +909,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)
|
||||
@@ -932,6 +1043,10 @@ func persistLogsToStore(
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if loghub.IsHTTPAccessEntry(entry) {
|
||||
continue
|
||||
}
|
||||
entry = loghub.SanitizeEntry(entry)
|
||||
var fields json.RawMessage
|
||||
if len(entry.Fields) > 0 {
|
||||
if raw, err := json.Marshal(entry.Fields); err == nil {
|
||||
@@ -1079,6 +1194,7 @@ func reconcileCardPolicies(
|
||||
vowifiManager *vowifiruntime.Manager,
|
||||
) {
|
||||
observedCards := make(map[string]string)
|
||||
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
|
||||
reconcile := func() {
|
||||
policies, policyListErr := database.ListCardPolicies(ctx)
|
||||
if policyListErr == nil {
|
||||
@@ -1160,6 +1276,9 @@ func reconcileCardPolicies(
|
||||
}
|
||||
switch {
|
||||
case stateErr != nil || !state.Enabled:
|
||||
if shouldDelayWiFi410VoWiFi(config.DeviceType, time.Now(), wifi410StartupNotBefore) {
|
||||
continue
|
||||
}
|
||||
_, _ = vowifiManager.RequestEnabled(config.ID, true)
|
||||
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
@@ -1302,3 +1421,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 ""
|
||||
}
|
||||
|
||||
@@ -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_*` 环境变量。环境变量优先级更高。
|
||||
|
||||
@@ -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 保留。前端构建用于验证新增表单与类型契约。
|
||||
@@ -5,10 +5,12 @@ go 1.25.0
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
||||
github.com/warthog618/sms v0.3.0
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.41.0
|
||||
howett.net/plist v1.0.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
@@ -21,7 +23,6 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/warthog618/sms v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -46,6 +46,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
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=
|
||||
|
||||
@@ -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):]
|
||||
|
||||
@@ -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()
|
||||
|
||||
+31
-30
@@ -330,6 +330,18 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
||||
// operation self-healing without disturbing an active AKA exchange.
|
||||
continue
|
||||
}
|
||||
if attempt == 1 && isTransientEuiccCME(err) {
|
||||
// When SIM hot-swap occurs or the modem baseband APDU channel is stuck (+CME ERROR: 0),
|
||||
// perform a soft SIM subsystem reset (AT+CFUN=0 -> AT+CFUN=1/4) to re-initialize
|
||||
// card interface voltage and ATR without restarting the whole hardware module.
|
||||
_ = manager.softResetForProfileSwitch(ctx, id)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(600 * time.Millisecond):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isTransientEuiccCME(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -427,6 +439,9 @@ func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, cand
|
||||
}
|
||||
const slot uint8 = 1
|
||||
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid)
|
||||
if recoverySession, recoveryOK := session.(nativeQMIChannelRecoverySession); recoveryOK && isQMIInsufficientResources(err) {
|
||||
logicalChannel, err = openNativeQMIChannelWithRecovery(openContext, recoverySession, slot, aid)
|
||||
}
|
||||
if err != nil {
|
||||
_ = session.Close()
|
||||
return nil, fmt.Errorf("%w: %v", errNoEUICC, err)
|
||||
@@ -1071,15 +1086,14 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
||||
manager.esimCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// recoverAfterProfileSwitch owns the post-commit reset independently of the
|
||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
||||
// recoverAfterProfileSwitch owns the post-commit SIM reset independently of the
|
||||
// initiating HTTP request.
|
||||
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
|
||||
cancelReset()
|
||||
if err == nil {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
// Native WWAN identity and profile verification are both QMI-backed.
|
||||
// Do not enter the AT refresh path: OpenStick firmware can accept the
|
||||
@@ -1088,52 +1102,39 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
}
|
||||
cancelReset()
|
||||
if !manager.isPCSCDevice(id) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||
_ = manager.softResetForProfileSwitch(resetContext, id)
|
||||
cancelReset()
|
||||
}
|
||||
manager.refreshAfterProfileSwitch(id)
|
||||
}
|
||||
|
||||
// refreshAfterProfileSwitch repopulates the device snapshot in the background
|
||||
// after an eSIM profile switch + modem reboot. /overview only serves the cached
|
||||
// snapshot, and nothing else live-reads post-switch, so without this the card
|
||||
// stays on "--" forever. The EC20 takes ~10-15s to come back from AT+CFUN=1,1,
|
||||
// so we delay first, then retry with backoff. Transport errors during the
|
||||
// reboot window are fine — Fix 1 discards the poisoned client and reopens on
|
||||
// the next attempt. All errors are swallowed: this is best-effort self-healing
|
||||
// and setResult already records the last failure for the UI.
|
||||
// after an eSIM profile switch.
|
||||
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
if manager.isPCSCDevice(id) {
|
||||
time.Sleep(750 * time.Millisecond)
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||
_, _ = manager.Discover(ctx)
|
||||
_, err := manager.Refresh(ctx, id)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return
|
||||
}
|
||||
const (
|
||||
settle = 8 * time.Second
|
||||
interval = 4 * time.Second
|
||||
attempts = 6
|
||||
settle = 1 * time.Second
|
||||
interval = 1 * time.Second
|
||||
attempts = 5
|
||||
)
|
||||
time.Sleep(settle)
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
||||
_, _ = manager.Discover(ctx)
|
||||
_, flightErr := manager.SetFlight(ctx, id, true)
|
||||
var err error
|
||||
if flightErr == nil {
|
||||
_, err = manager.Refresh(ctx, id)
|
||||
} else {
|
||||
err = flightErr
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||
_, err := manager.Refresh(ctx, id)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
@@ -1233,7 +1234,7 @@ func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
|
||||
// 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)
|
||||
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 1*time.Second)
|
||||
}
|
||||
|
||||
func (manager *Manager) verifySwitchedICCIDAttempts(
|
||||
|
||||
@@ -30,9 +30,9 @@ func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address
|
||||
}
|
||||
|
||||
func TestParsePendingNotifications(t *testing.T) {
|
||||
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
|
||||
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944470000000000001")
|
||||
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
|
||||
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
|
||||
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "8944100000000000001")
|
||||
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
||||
|
||||
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
||||
@@ -44,11 +44,11 @@ func TestParsePendingNotifications(t *testing.T) {
|
||||
}
|
||||
// Results are grouped by receiver, then sorted by sequence number.
|
||||
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
||||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
|
||||
got.Address != "delete.example.com" || got.ICCID != "8944100000000000001" || !bytes.Equal(got.raw, deleted) {
|
||||
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
||||
}
|
||||
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
||||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
|
||||
got.Address != "install.example.com" || got.ICCID != "8944470000000000001" || !bytes.Equal(got.raw, install) {
|
||||
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,9 +55,9 @@ func esimTestProfile(t *testing.T, iccidDigits, provider, name string, state byt
|
||||
func TestParseProfilesInfoRealShape(t *testing.T) {
|
||||
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
||||
body := tlv([]byte{0xA0},
|
||||
esimTestProfile(t, "89441000400128014257", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
||||
esimTestProfile(t, "89441000430011604140", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
||||
esimTestProfile(t, "89852351225001058508", "Webbing", "WEBBING", 0x00),
|
||||
esimTestProfile(t, "8944100000000000001", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
||||
esimTestProfile(t, "8944100000000000002", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
||||
esimTestProfile(t, "8985200000000000001", "Webbing", "WEBBING", 0x00),
|
||||
)
|
||||
payload := tlv([]byte{0xBF, 0x2D}, body)
|
||||
|
||||
@@ -65,10 +65,10 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
|
||||
if len(profiles) != 3 {
|
||||
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
|
||||
}
|
||||
if profiles[0].ICCID != "89441000400128014257" || profiles[0].State != 0 {
|
||||
if profiles[0].ICCID != "8944100000000000001" || profiles[0].State != 0 {
|
||||
t.Fatalf("profile[0] = %#v", profiles[0])
|
||||
}
|
||||
if profiles[1].ICCID != "89441000430011604140" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
|
||||
if profiles[1].ICCID != "8944100000000000002" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
|
||||
t.Fatalf("profile[1] = %#v", profiles[1])
|
||||
}
|
||||
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
|
||||
@@ -82,8 +82,8 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
||||
real := esimTestProfile(t, "89441000400316048687", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
||||
duplicate := esimTestProfile(t, "89441000400316048687", "Duplicate", "Duplicate", 0x00)
|
||||
real := esimTestProfile(t, "8944100000000000003", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
||||
duplicate := esimTestProfile(t, "8944100000000000003", "Duplicate", "Duplicate", 0x00)
|
||||
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
||||
empty := tlv([]byte{0xE3})
|
||||
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
|
||||
@@ -92,13 +92,13 @@ func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
||||
if len(profiles) != 1 {
|
||||
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
|
||||
}
|
||||
if profiles[0].ICCID != "89441000400316048687" || profiles[0].Name != "Vodafone UK eSIM" {
|
||||
if profiles[0].ICCID != "8944100000000000003" || profiles[0].Name != "Vodafone UK eSIM" {
|
||||
t.Fatalf("profile = %#v", profiles[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestICCIDRoundTrip(t *testing.T) {
|
||||
for _, digits := range []string{"89441000400128014257", "8985235122500105850", "1"} {
|
||||
for _, digits := range []string{"8944100000000000001", "8985200000000000001", "1"} {
|
||||
bcd, err := encodeICCID(digits)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
||||
@@ -110,7 +110,7 @@ func TestICCIDRoundTrip(t *testing.T) {
|
||||
t.Fatalf("round trip %q -> %q", digits, got)
|
||||
}
|
||||
}
|
||||
if _, err := encodeICCID("894410004001280142571"); err == nil {
|
||||
if _, err := encodeICCID("894410000000000000001"); err == nil {
|
||||
t.Fatal("21-digit ICCID was accepted")
|
||||
}
|
||||
}
|
||||
@@ -126,11 +126,11 @@ func TestEnableProfileRequestPads18DigitICCIDToTenOctets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
||||
request, err := buildDeleteProfileRequest("89441000400128014257")
|
||||
request, err := buildDeleteProfileRequest("89441000000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100041082102475" {
|
||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100000000000010" {
|
||||
t.Fatalf("DeleteProfile request = %s", got)
|
||||
}
|
||||
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
||||
@@ -144,28 +144,28 @@ func TestDeleteProfileRequestAndResult(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetNicknameRequestAndResult(t *testing.T) {
|
||||
request, err := buildSetNicknameRequest("89441000400128014257", "Test")
|
||||
request, err := buildSetNicknameRequest("89441000000000000001", "Test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100041082102475900454657374" {
|
||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100000000000010900454657374" {
|
||||
t.Fatalf("SetNickname request = %s", got)
|
||||
}
|
||||
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
||||
if !ok || result != 0 {
|
||||
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
|
||||
}
|
||||
if _, err := buildSetNicknameRequest("89441000400128014257", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
|
||||
if _, err := buildSetNicknameRequest("89441000000000000001", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
|
||||
t.Fatalf("long nickname error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableProfileRequestAndResult(t *testing.T) {
|
||||
request, err := buildDisableProfileRequest("89441000400128014257")
|
||||
request, err := buildDisableProfileRequest("89441000000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000410821024758101FF" {
|
||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000000000000108101FF" {
|
||||
t.Fatalf("DisableProfile request = %s", got)
|
||||
}
|
||||
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
||||
@@ -210,7 +210,7 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.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: 8944100000000000001F")},
|
||||
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
+76
-13
@@ -206,6 +206,11 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
|
||||
type discoveryEvent struct {
|
||||
connected bool
|
||||
candidate modem.Candidate
|
||||
}
|
||||
events := make([]discoveryEvent, 0)
|
||||
manager.mu.Lock()
|
||||
for _, candidate := range candidates {
|
||||
if strings.TrimSpace(candidate.ID) == "" {
|
||||
@@ -218,8 +223,12 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
candidate: candidate,
|
||||
discovered: true,
|
||||
}
|
||||
events = append(events, discoveryEvent{connected: true, candidate: candidate})
|
||||
continue
|
||||
}
|
||||
if !state.discovered {
|
||||
events = append(events, discoveryEvent{connected: true, candidate: candidate})
|
||||
}
|
||||
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
|
||||
state.resetClientOnLock = true
|
||||
}
|
||||
@@ -231,10 +240,28 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
if state.discovered {
|
||||
events = append(events, discoveryEvent{candidate: state.candidate})
|
||||
}
|
||||
state.discovered = false
|
||||
stale = append(stale, state)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
if manager.logger != nil {
|
||||
for _, event := range events {
|
||||
message := "hardware disconnected"
|
||||
if event.connected {
|
||||
message = "hardware connected"
|
||||
}
|
||||
manager.logger.Info(message,
|
||||
"event", "hardware.discovery",
|
||||
"device_id", event.candidate.ID,
|
||||
"hardware_kind", event.candidate.HardwareKind,
|
||||
"vendor_id", event.candidate.VendorID,
|
||||
"product_id", event.candidate.ProductID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, state := range stale {
|
||||
state.opMu.Lock()
|
||||
@@ -382,6 +409,11 @@ func (manager *Manager) setResult(
|
||||
return
|
||||
}
|
||||
previousError := state.lastError
|
||||
var previousSnapshot *Snapshot
|
||||
if state.snapshot != nil {
|
||||
value := *state.snapshot
|
||||
previousSnapshot = &value
|
||||
}
|
||||
if snapshot != nil {
|
||||
value := *snapshot
|
||||
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
||||
@@ -394,6 +426,13 @@ func (manager *Manager) setResult(
|
||||
state.lastError = ""
|
||||
}
|
||||
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
|
||||
registrationChanged := snapshot != nil && manager.logger != nil &&
|
||||
(previousSnapshot == nil ||
|
||||
previousSnapshot.RegistrationStatus != snapshot.RegistrationStatus ||
|
||||
previousSnapshot.OperatorCode != snapshot.OperatorCode ||
|
||||
previousSnapshot.AccessTech != snapshot.AccessTech ||
|
||||
previousSnapshot.PSAttached != snapshot.PSAttached ||
|
||||
previousSnapshot.SIMStatus != snapshot.SIMStatus)
|
||||
backend := state.backend
|
||||
hardwareKind := state.candidate.HardwareKind
|
||||
manager.mu.Unlock()
|
||||
@@ -406,6 +445,21 @@ func (manager *Manager) setResult(
|
||||
"error", HardwareErrorDetail(err),
|
||||
)
|
||||
}
|
||||
if registrationChanged {
|
||||
manager.logger.Info(
|
||||
"cellular registration state changed",
|
||||
"category", "network",
|
||||
"event", "network.registration",
|
||||
"device_id", id,
|
||||
"sim_status", snapshot.SIMStatus,
|
||||
"registration_status", snapshot.RegistrationStatus,
|
||||
"registration_source", snapshot.RegistrationSource,
|
||||
"operator", snapshot.OperatorName,
|
||||
"operator_code", snapshot.OperatorCode,
|
||||
"access_technology", snapshot.AccessTech,
|
||||
"packet_service_attached", snapshot.PSAttached,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
||||
@@ -631,13 +685,11 @@ func (manager *Manager) Reboot(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// rebootForProfileSwitch is the post-EnableProfile modem reset. After the eUICC
|
||||
// marks a new profile active, the modem keeps the old SIM cached and lands in
|
||||
// SIM failure (-CME 13) until it is bounced. ESIMSwitchProfile has already
|
||||
// released opMu by the time it calls this, so the reset is safe to take the
|
||||
// lock. This mirrors Reboot but is separate so the call site can't recurse into
|
||||
// a guarded-reset path.
|
||||
func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) error {
|
||||
// softResetForProfileSwitch resets the baseband SIM stack using a soft CFUN sequence
|
||||
// (AT+CFUN=0 -> AT+CFUN=1/4) instead of rebooting the entire hardware module (AT+CFUN=1,1).
|
||||
// This causes the baseband to reload the new eSIM profile files within ~1-2 seconds
|
||||
// without disconnecting USB/PCIe or dropping serial communication ports.
|
||||
func (manager *Manager) softResetForProfileSwitch(ctx context.Context, id string) error {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -652,14 +704,25 @@ func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) e
|
||||
manager.setResult(id, state, nil, err)
|
||||
return err
|
||||
}
|
||||
commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
|
||||
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
|
||||
defer cancel()
|
||||
_, err = client.Execute(commandCtx, "AT+CFUN=1,1")
|
||||
if closeErr := client.Close(); err == nil {
|
||||
err = closeErr
|
||||
|
||||
// 1. Cycle SIM interface to minimum functionality / clear cached SIM files
|
||||
_, _ = client.Execute(commandCtx, "AT+CFUN=0")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
state.client = nil
|
||||
state.preFlightMode = nil
|
||||
|
||||
// 2. Restore radio to trigger fresh USIM file reading
|
||||
targetCFUN := "AT+CFUN=1"
|
||||
if state.snapshot != nil && state.snapshot.FlightMode {
|
||||
targetCFUN = "AT+CFUN=4"
|
||||
}
|
||||
_, err = client.Execute(commandCtx, targetCFUN)
|
||||
|
||||
manager.clearSnapshot(id, state)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return err
|
||||
|
||||
@@ -91,11 +91,11 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
wantPLMN string
|
||||
wantCountry string
|
||||
}{
|
||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
||||
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
|
||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
||||
{imsi: "234330000000001", wantPLMN: "23433", wantCountry: "GB"},
|
||||
{imsi: "234150000000001", wantPLMN: "23415", wantCountry: "GB"},
|
||||
{imsi: "234870000000001", wantPLMN: "23487", wantCountry: "GB"},
|
||||
{imsi: "454000000000001", wantPLMN: "45400", wantCountry: "HK"},
|
||||
{imsi: "310260000000001", wantPLMN: "310260", wantCountry: "US"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
||||
|
||||
+266
-14
@@ -8,7 +8,13 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/warthog618/sms/encoding/gsm7"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
var gsm7DefaultAlphabet = [128]rune{
|
||||
@@ -548,6 +554,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 +772,34 @@ 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
|
||||
// is a count of useful semi-octets, not a character count. In particular,
|
||||
// a three-character sender such as "OKX" has length 6. Treating every
|
||||
// short length as a septet count consumes PID/DCS bytes as part of the
|
||||
// address and shifts the entire TPDU, producing plausible-looking GSM-7
|
||||
// garbage instead of the message body.
|
||||
byteCount = (int(length) + 1) / 2
|
||||
septetCount = int(length) * 4 / 7
|
||||
|
||||
// A few legacy/non-standard sources do put the character count in this
|
||||
// field. Retain compatibility only when the standard-sized value cannot
|
||||
// be a valid zero-padded GSM-7 address; do not guess based on its length.
|
||||
if byteCount == 0 || cursor.index+byteCount > len(cursor.data) ||
|
||||
!hasZeroGSM7Padding(cursor.data[cursor.index:cursor.index+byteCount], septetCount) {
|
||||
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
|
||||
@@ -774,6 +809,18 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
||||
return decodeNumericAddress(value, int(length), toa), nil
|
||||
}
|
||||
|
||||
func hasZeroGSM7Padding(data []byte, septetCount int) bool {
|
||||
if septetCount <= 0 || septetCount*7 > len(data)*8 {
|
||||
return false
|
||||
}
|
||||
for bit := septetCount * 7; bit < len(data)*8; bit++ {
|
||||
if data[bit/8]&(byte(1)<<uint(bit%8)) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeNumericAddress(value []byte, digits int, toa byte) string {
|
||||
var result strings.Builder
|
||||
if toa&0x70 == 0x10 {
|
||||
@@ -801,9 +848,9 @@ func decodeUserData(
|
||||
udl int,
|
||||
message *SMSMessage,
|
||||
) error {
|
||||
alphabet := dcs & 0x0c
|
||||
alphabet := decodeSMSAlphabet(dcs)
|
||||
expectedBytes := udl
|
||||
if alphabet == 0 {
|
||||
if alphabet == smsAlphabetGSM7 {
|
||||
expectedBytes = (udl*7 + 7) / 8
|
||||
}
|
||||
if expectedBytes > len(data) {
|
||||
@@ -824,8 +871,12 @@ func decodeUserData(
|
||||
message.Concat = parseConcatHeader(data[1:headerBytes])
|
||||
}
|
||||
|
||||
var header []byte
|
||||
if headerBytes > 0 {
|
||||
header = data[1:headerBytes]
|
||||
}
|
||||
switch alphabet {
|
||||
case 0:
|
||||
case smsAlphabetGSM7:
|
||||
message.Encoding = SMSEncodingGSM7PDU
|
||||
headerSeptets := 0
|
||||
if headerBytes > 0 {
|
||||
@@ -836,27 +887,228 @@ func decodeUserData(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := decodeGSM7(septets)
|
||||
text, err := decodeGSM7WithHeader(septets, header)
|
||||
message.Text = text
|
||||
return err
|
||||
case 8:
|
||||
case smsAlphabetUCS2:
|
||||
message.Encoding = SMSEncodingUCS2PDU
|
||||
payload := data[headerBytes:]
|
||||
if len(payload)%2 != 0 {
|
||||
return errors.New("UCS2 SMS has an odd byte count")
|
||||
text, ok := decodeUTF16Bytes(payload)
|
||||
if ok {
|
||||
message.Text = text
|
||||
return nil
|
||||
}
|
||||
units := make([]uint16, 0, len(payload)/2)
|
||||
for index := 0; index < len(payload); index += 2 {
|
||||
units = append(units, uint16(payload[index])<<8|uint16(payload[index+1]))
|
||||
// Some gateways label UTF-8 or a local 8-bit character set as UCS-2.
|
||||
// Only accept a fallback when it is unambiguously readable text.
|
||||
if text, encoding, detected := decodeTextBytes(payload, header); detected {
|
||||
message.Text = text
|
||||
message.Encoding = encoding
|
||||
return nil
|
||||
}
|
||||
message.Text = string(utf16.Decode(units))
|
||||
return nil
|
||||
return errors.New("UCS2 SMS has invalid UTF-16 data")
|
||||
default:
|
||||
payload := data[headerBytes:]
|
||||
if text, encoding, detected := decodeTextBytes(payload, header); detected {
|
||||
message.Text = text
|
||||
message.Encoding = encoding
|
||||
return nil
|
||||
}
|
||||
// Port-addressed or non-text 8-bit data remains hexadecimal, preserving
|
||||
// binary SMS (WAP push, provisioning, SIM data) without lossy guessing.
|
||||
message.Encoding = SMSEncoding8BitPDU
|
||||
message.Text = strings.ToUpper(hex.EncodeToString(payload))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type smsAlphabet byte
|
||||
|
||||
const (
|
||||
smsAlphabetGSM7 smsAlphabet = iota
|
||||
smsAlphabet8Bit
|
||||
smsAlphabetUCS2
|
||||
smsAlphabetUnknown
|
||||
)
|
||||
|
||||
// decodeSMSAlphabet applies the complete 3GPP TS 23.038 DCS grouping rules.
|
||||
// A plain dcs&0x0c check is incorrect for message-waiting groups Cx/Dx/Ex and
|
||||
// reserved coding groups, and can silently select the wrong decoder.
|
||||
func decodeSMSAlphabet(dcs byte) smsAlphabet {
|
||||
switch {
|
||||
case dcs&0x80 == 0:
|
||||
if dcs&0x20 != 0 { // GSM compression is not safely decodable here.
|
||||
return smsAlphabetUnknown
|
||||
}
|
||||
switch (dcs >> 2) & 0x03 {
|
||||
case 0:
|
||||
return smsAlphabetGSM7
|
||||
case 1:
|
||||
return smsAlphabet8Bit
|
||||
case 2:
|
||||
return smsAlphabetUCS2
|
||||
default:
|
||||
return smsAlphabetUnknown
|
||||
}
|
||||
case dcs&0xe0 == 0xc0: // Cx and Dx message-waiting groups use GSM-7.
|
||||
return smsAlphabetGSM7
|
||||
case dcs&0xf0 == 0xe0: // Ex message-waiting group uses UCS-2.
|
||||
return smsAlphabetUCS2
|
||||
case dcs&0xf0 == 0xf0:
|
||||
if dcs&0x04 != 0 {
|
||||
return smsAlphabet8Bit
|
||||
}
|
||||
return smsAlphabetGSM7
|
||||
default:
|
||||
return smsAlphabetUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func decodeGSM7WithHeader(septets, header []byte) (string, error) {
|
||||
locking, hasLocking := userDataHeaderLanguage(header, 0x25)
|
||||
shift, hasShift := userDataHeaderLanguage(header, 0x24)
|
||||
if !hasLocking && !hasShift {
|
||||
return decodeGSM7(septets)
|
||||
}
|
||||
options := make([]gsm7.DecoderOption, 0, 2)
|
||||
if hasLocking {
|
||||
options = append(options, gsm7.WithCharset(locking))
|
||||
}
|
||||
if hasShift {
|
||||
options = append(options, gsm7.WithExtCharset(shift))
|
||||
}
|
||||
decoded, err := gsm7.Decode(septets, options...)
|
||||
return string(decoded), err
|
||||
}
|
||||
|
||||
func userDataHeaderLanguage(header []byte, identifier byte) (int, bool) {
|
||||
for index := 0; index+1 < len(header); {
|
||||
id := header[index]
|
||||
length := int(header[index+1])
|
||||
index += 2
|
||||
if index+length > len(header) {
|
||||
return 0, false
|
||||
}
|
||||
if id == identifier && length == 1 {
|
||||
return int(header[index]), true
|
||||
}
|
||||
index += length
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func decodeUTF16Bytes(payload []byte) (string, bool) {
|
||||
if len(payload) == 0 {
|
||||
return "", true
|
||||
}
|
||||
if len(payload)%2 != 0 {
|
||||
return "", false
|
||||
}
|
||||
littleEndian := len(payload) >= 2 && payload[0] == 0xff && payload[1] == 0xfe
|
||||
if (payload[0] == 0xfe && payload[1] == 0xff) || littleEndian {
|
||||
payload = payload[2:]
|
||||
}
|
||||
units := make([]uint16, 0, len(payload)/2)
|
||||
for index := 0; index < len(payload); index += 2 {
|
||||
unit := uint16(payload[index])<<8 | uint16(payload[index+1])
|
||||
if littleEndian {
|
||||
unit = uint16(payload[index+1])<<8 | uint16(payload[index])
|
||||
}
|
||||
units = append(units, unit)
|
||||
}
|
||||
text := string(utf16.Decode(units))
|
||||
return text, !strings.ContainsRune(text, unicode.ReplacementChar) && readableText(text)
|
||||
}
|
||||
|
||||
func decodeTextBytes(payload, header []byte) (string, SMSEncoding, bool) {
|
||||
if hasApplicationPortAddressing(header) || len(payload) == 0 {
|
||||
return "", SMSEncoding8BitPDU, false
|
||||
}
|
||||
if len(payload) >= 2 && ((payload[0] == 0xfe && payload[1] == 0xff) ||
|
||||
(payload[0] == 0xff && payload[1] == 0xfe)) {
|
||||
if text, ok := decodeUTF16Bytes(payload); ok {
|
||||
return text, SMSEncodingUCS2PDU, true
|
||||
}
|
||||
}
|
||||
if utf8.Valid(payload) {
|
||||
text := string(payload)
|
||||
if readableText(text) {
|
||||
return text, SMSEncodingUTF8PDU, true
|
||||
}
|
||||
}
|
||||
if containsNonASCII(payload) {
|
||||
decoded, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), payload)
|
||||
text := string(decoded)
|
||||
if err == nil && strings.ContainsFunc(text, func(character rune) bool {
|
||||
return unicode.Is(unicode.Han, character)
|
||||
}) && readableText(text) {
|
||||
return text, SMSEncodingGB18030, true
|
||||
}
|
||||
}
|
||||
if text, ok := decodeLatin1Text(payload); ok {
|
||||
return text, SMSEncodingLatin1, true
|
||||
}
|
||||
return "", SMSEncoding8BitPDU, false
|
||||
}
|
||||
|
||||
func readableText(text string) bool {
|
||||
if text == "" {
|
||||
return true
|
||||
}
|
||||
printable, total := 0, 0
|
||||
for _, character := range text {
|
||||
total++
|
||||
if unicode.IsPrint(character) || character == '\n' || character == '\r' || character == '\t' {
|
||||
printable++
|
||||
}
|
||||
}
|
||||
return printable*100 >= total*90
|
||||
}
|
||||
|
||||
func containsNonASCII(data []byte) bool {
|
||||
for _, value := range data {
|
||||
if value >= utf8.RuneSelf {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeLatin1Text(payload []byte) (string, bool) {
|
||||
characters := make([]rune, 0, len(payload))
|
||||
ascii := 0
|
||||
for _, value := range payload {
|
||||
switch {
|
||||
case value == '\n' || value == '\r' || value == '\t' || value >= 0x20 && value <= 0x7e:
|
||||
ascii++
|
||||
case value >= 0xa0:
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
characters = append(characters, rune(value))
|
||||
}
|
||||
if ascii == 0 || ascii*2 < len(payload) {
|
||||
return "", false
|
||||
}
|
||||
text := string(characters)
|
||||
return text, readableText(text)
|
||||
}
|
||||
|
||||
func hasApplicationPortAddressing(header []byte) bool {
|
||||
for index := 0; index+1 < len(header); {
|
||||
identifier := header[index]
|
||||
length := int(header[index+1])
|
||||
index += 2
|
||||
if index+length > len(header) {
|
||||
return true
|
||||
}
|
||||
if (identifier == 0x04 && length == 2) || (identifier == 0x05 && length == 4) {
|
||||
return true
|
||||
}
|
||||
index += length
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseConcatHeader(header []byte) *SMSConcatInfo {
|
||||
for index := 0; index+1 < len(header); {
|
||||
identifier := header[index]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -263,3 +264,193 @@ 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 TestDecodeDeliverPDUWithShortStandardAlphanumericSender(t *testing.T) {
|
||||
// TP-OA length is expressed in useful semi-octets. The three-character
|
||||
// sender "OKX" therefore has length 6, even though it contains 3 septets.
|
||||
// A previous short-address heuristic interpreted 6 as the character count
|
||||
// and swallowed PID, DCS, and timestamp bytes into the sender address.
|
||||
text := "Your OKX verification code is: 123456"
|
||||
textSeptets, ok := encodeGSM7(text)
|
||||
if !ok {
|
||||
t.Fatal("test text is not GSM-7 encodable")
|
||||
}
|
||||
pdu := []byte{0x00, 0x04, 0x06, 0xd0}
|
||||
pdu = append(pdu, packSeptets([]byte{'O', 'K', 'X'}, 0)...)
|
||||
pdu = append(pdu,
|
||||
0x00, 0x00, // PID and GSM-7 DCS.
|
||||
0x62, 0x80, 0x20, 0x91, 0x40, 0x95, 0x00, // 2026-08-02 19:04:59 UTC.
|
||||
byte(len(textSeptets)),
|
||||
)
|
||||
pdu = append(pdu, packSeptets(textSeptets, 0)...)
|
||||
|
||||
message, err := decodeSMSPDU(hex.EncodeToString(pdu))
|
||||
if err != nil {
|
||||
t.Fatalf("decode short alphanumeric sender: %v", err)
|
||||
}
|
||||
if message.From != "OKX" || message.Text != text ||
|
||||
message.Encoding != SMSEncodingGSM7PDU || message.DataCodingScheme != 0 {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeUserDataUnderstandsDCSGroups(t *testing.T) {
|
||||
septets, ok := encodeGSM7("HELLO")
|
||||
if !ok {
|
||||
t.Fatal("encode GSM-7 test text")
|
||||
}
|
||||
packed := packSeptets(septets, 0)
|
||||
for _, dcs := range []byte{0x00, 0xc8, 0xd0, 0xf0} {
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(packed, 0, dcs, len(septets), &message); err != nil {
|
||||
t.Fatalf("decode DCS 0x%02X: %v", dcs, err)
|
||||
}
|
||||
if message.Text != "HELLO" || message.Encoding != SMSEncodingGSM7PDU {
|
||||
t.Fatalf("DCS 0x%02X message = %#v", dcs, message)
|
||||
}
|
||||
}
|
||||
|
||||
ucs2 := []byte{0x4f, 0x60, 0x59, 0x7d}
|
||||
for _, dcs := range []byte{0x08, 0xe0} {
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(ucs2, 0, dcs, len(ucs2), &message); err != nil {
|
||||
t.Fatalf("decode DCS 0x%02X: %v", dcs, err)
|
||||
}
|
||||
if message.Text != "你好" || message.Encoding != SMSEncodingUCS2PDU {
|
||||
t.Fatalf("DCS 0x%02X message = %#v", dcs, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGSM7NationalLanguageTables(t *testing.T) {
|
||||
// National language locking shift IEI 0x25, Turkish table 1. In that
|
||||
// locking table septet 0x07 is the dotless i (ı), rather than default ì.
|
||||
header := []byte{0x03, 0x25, 0x01, 0x01}
|
||||
headerSeptets := (len(header)*8 + 6) / 7
|
||||
data := packSeptets([]byte{0x07}, headerSeptets*7)
|
||||
copy(data, header)
|
||||
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(data, 0x40, 0x00, headerSeptets+1, &message); err != nil {
|
||||
t.Fatalf("decode Turkish locking table: %v", err)
|
||||
}
|
||||
if message.Text != "ı" || message.Encoding != SMSEncodingGSM7PDU {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode8BitTextEncodingsAndPreservesBinary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
wantText string
|
||||
encoding SMSEncoding
|
||||
}{
|
||||
{name: "UTF-8", payload: []byte("验证码 123456"), wantText: "验证码 123456", encoding: SMSEncodingUTF8PDU},
|
||||
{name: "GB18030", payload: []byte{0xd1, 0xe9, 0xd6, 0xa4, 0xc2, 0xeb}, wantText: "验证码", encoding: SMSEncodingGB18030},
|
||||
{name: "Latin-1", payload: []byte{'C', 'a', 'f', 0xe9}, wantText: "Café", encoding: SMSEncodingLatin1},
|
||||
{name: "binary", payload: []byte{0xaa, 0xbb, 0xcc}, wantText: "AABBCC", encoding: SMSEncoding8BitPDU},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(test.payload, 0, 0x04, len(test.payload), &message); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if message.Text != test.wantText || message.Encoding != test.encoding {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePortAddressed8BitSMSRemainsBinary(t *testing.T) {
|
||||
header := []byte{0x04, 0x04, 0x02, 0x0b, 0x84}
|
||||
data := append(append([]byte(nil), header...), []byte("plain-looking payload")...)
|
||||
message := SMSMessage{}
|
||||
if err := decodeUserData(data, 0x40, 0x04, len(data), &message); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
payload := data[len(header):]
|
||||
if message.Text != strings.ToUpper(hex.EncodeToString(payload)) ||
|
||||
message.Encoding != SMSEncoding8BitPDU {
|
||||
t.Fatalf("message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,32 @@ func (manager *Manager) readSnapshot(
|
||||
if response, ok := optional("AT+CPIN?"); ok {
|
||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||
}
|
||||
previousICCID = strings.TrimSpace(previousICCID)
|
||||
if !snapshot.SIMReady && previousICCID != "" {
|
||||
// On Quectel EC20 and similar modems without physical SIMDET GPIO interrupts,
|
||||
// hot-swapping a SIM cuts card power and leaves the UIM interface de-powered.
|
||||
// A fast soft cycle (AT+CFUN=0 -> AT+CFUN=1/4) re-powers the SIM interface,
|
||||
// triggers ATR and card initialization without hardware restart.
|
||||
_, _ = manager.command(ctx, client, "AT+CFUN=0")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return snapshot, ctx.Err()
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
targetCFUN := "AT+CFUN=1"
|
||||
if snapshot.FlightMode {
|
||||
targetCFUN = "AT+CFUN=4"
|
||||
}
|
||||
_, _ = manager.command(ctx, client, targetCFUN)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return snapshot, ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
if response, ok := optional("AT+CPIN?"); ok {
|
||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||
}
|
||||
}
|
||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
@@ -92,14 +118,11 @@ func (manager *Manager) readSnapshot(
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
}
|
||||
}
|
||||
previousICCID = strings.TrimSpace(previousICCID)
|
||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||
// A different physical SIM must never inherit the previous card's
|
||||
// permission to use cellular RF. Disable RF before reading serving-cell
|
||||
// or operator state; policy reconciliation will then start VoWiFi.
|
||||
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
|
||||
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
|
||||
}
|
||||
_, _ = manager.command(ctx, client, "AT+CFUN=4")
|
||||
snapshot.SIMChanged = true
|
||||
}
|
||||
if response, ok := optional("AT+CIMI"); ok {
|
||||
|
||||
@@ -146,6 +146,9 @@ const (
|
||||
SMSEncodingGSM7Text SMSEncoding = "gsm7_text"
|
||||
SMSEncodingGSM7PDU SMSEncoding = "gsm7_pdu"
|
||||
SMSEncodingUCS2PDU SMSEncoding = "ucs2_pdu"
|
||||
SMSEncodingUTF8PDU SMSEncoding = "utf8_pdu"
|
||||
SMSEncodingGB18030 SMSEncoding = "gb18030_pdu"
|
||||
SMSEncodingLatin1 SMSEncoding = "latin1_pdu"
|
||||
SMSEncoding8BitPDU SMSEncoding = "8bit_pdu"
|
||||
SMSEncodingUnknown SMSEncoding = "unknown"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
|
||||
)
|
||||
|
||||
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error {
|
||||
@@ -70,7 +72,7 @@ func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, prefe
|
||||
|
||||
func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) {
|
||||
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
|
||||
channel, openErr := session.OpenLogicalChannel(ctx, 1, aid)
|
||||
channel, openErr := openNativeQMIChannelWithRecovery(ctx, session, 1, aid)
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open QMI UIM logical channel: %w", openErr)
|
||||
}
|
||||
@@ -102,6 +104,60 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai
|
||||
return
|
||||
}
|
||||
|
||||
type nativeQMIChannelRecoverySession interface {
|
||||
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
|
||||
PowerOffSIM(context.Context, uint8) error
|
||||
PowerOnSIM(context.Context, uint8) error
|
||||
}
|
||||
|
||||
// OpenStick 410 can leave the physical UICC powered but unable to allocate a
|
||||
// logical channel after a SIM hot-swap. A UIM service reset alone does not
|
||||
// clear that state; cycling the affected physical slot does. Recover only the
|
||||
// precise QMI InsufficientResources response, then retry the original AID once.
|
||||
func openNativeQMIChannelWithRecovery(
|
||||
ctx context.Context,
|
||||
session nativeQMIChannelRecoverySession,
|
||||
slot uint8,
|
||||
aid []byte,
|
||||
) (byte, error) {
|
||||
channel, err := session.OpenLogicalChannel(ctx, slot, aid)
|
||||
if err == nil || !isQMIInsufficientResources(err) {
|
||||
return channel, err
|
||||
}
|
||||
if resetter, ok := session.(nativeQMIUIMResetSession); ok {
|
||||
_ = resetter.ResetUIM(ctx)
|
||||
}
|
||||
if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil {
|
||||
return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr))
|
||||
}
|
||||
if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil {
|
||||
return 0, errors.Join(err, waitErr)
|
||||
}
|
||||
if powerErr := session.PowerOnSIM(ctx, slot); powerErr != nil {
|
||||
return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr))
|
||||
}
|
||||
if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil {
|
||||
return 0, errors.Join(err, waitErr)
|
||||
}
|
||||
return session.OpenLogicalChannel(ctx, slot, aid)
|
||||
}
|
||||
|
||||
func isQMIInsufficientResources(err error) bool {
|
||||
qmiErr := qmi.GetQMIError(err)
|
||||
return qmiErr != nil && qmiErr.Service == qmi.ServiceUIM && qmiErr.ErrorCode == 0x0044
|
||||
}
|
||||
|
||||
var waitNativeQMIRecovery = func(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) {
|
||||
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
|
||||
qmiMode, modeErr := session.GetOperatingMode(ctx)
|
||||
|
||||
+23
-3
@@ -9,9 +9,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry is the stable, secret-neutral representation exposed by the log API.
|
||||
// Callers remain responsible for never adding credentials or keying material
|
||||
// to slog attributes.
|
||||
// Entry is the stable, centrally-redacted representation exposed by the log
|
||||
// API. The Hub sanitizes both the downstream handler and the captured entry so
|
||||
// diagnostic logs can be safely exported by users.
|
||||
type Entry struct {
|
||||
Time time.Time `json:"time"`
|
||||
Level string `json:"level"`
|
||||
@@ -58,6 +58,7 @@ func (h *Hub) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
}
|
||||
|
||||
func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
|
||||
record = sanitizeRecord(record)
|
||||
err := h.next.Handle(ctx, record)
|
||||
fields := make(map[string]any)
|
||||
for _, attr := range h.attrs {
|
||||
@@ -81,6 +82,7 @@ func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
|
||||
}
|
||||
|
||||
func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
attrs = sanitizeAttrs(attrs)
|
||||
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
|
||||
return &Hub{
|
||||
next: h.next.WithAttrs(attrs),
|
||||
@@ -180,6 +182,24 @@ func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) {
|
||||
return channel, cancel
|
||||
}
|
||||
|
||||
// Clear drops captured history and every entry currently queued for live and
|
||||
// persistence subscribers. Subscribers stay connected for future events.
|
||||
func (h *Hub) Clear() {
|
||||
h.core.mu.Lock()
|
||||
h.core.entries = h.core.entries[:0]
|
||||
for _, subscriber := range h.core.subscribers {
|
||||
for {
|
||||
select {
|
||||
case <-subscriber:
|
||||
continue
|
||||
default:
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
h.core.mu.Unlock()
|
||||
}
|
||||
|
||||
func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package loghub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +32,51 @@ func TestHubHistoryFiltersAndBounds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRedactsDownstreamAndHistoryAndPreservesErrors(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
hub := New(slog.NewJSONHandler(&output, nil), 100)
|
||||
logger := slog.New(hub).With("imsi", "234159611634973")
|
||||
logger.Warn(
|
||||
"delivery to +447700900123 failed",
|
||||
"iccid", "8944101234567890123",
|
||||
"peer", "+447700900456",
|
||||
"error", errors.New("modem rejected MSISDN=447700900789 with +CMS ERROR: 305"),
|
||||
)
|
||||
|
||||
entry := hub.History(1, slog.LevelDebug, "")[0]
|
||||
if strings.Contains(entry.Message, "447700900123") {
|
||||
t.Fatalf("message was not redacted: %q", entry.Message)
|
||||
}
|
||||
for _, key := range []string{"imsi", "iccid", "peer"} {
|
||||
if value := entry.Fields[key]; !strings.Contains(value.(string), "REDACTED") {
|
||||
t.Fatalf("%s = %#v, want redacted", key, value)
|
||||
}
|
||||
}
|
||||
errorText, ok := entry.Fields["error"].(string)
|
||||
if !ok || !strings.Contains(errorText, "+CMS ERROR: 305") || strings.Contains(errorText, "447700900789") {
|
||||
t.Fatalf("error = %#v, want original modem error with identity redacted", entry.Fields["error"])
|
||||
}
|
||||
if raw := output.String(); strings.Contains(raw, "234159611634973") || strings.Contains(raw, "447700900") {
|
||||
t.Fatalf("downstream output leaked an identity: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEntryProtectsLegacyNestedFields(t *testing.T) {
|
||||
entry := SanitizeEntry(Entry{
|
||||
Message: "incoming SIP from sip:[email protected]",
|
||||
Fields: map[string]any{
|
||||
"details": map[string]any{"associated_number": "+447700900456", "status": "registered"},
|
||||
},
|
||||
})
|
||||
if strings.Contains(entry.Message, "447700900123") {
|
||||
t.Fatalf("message = %q", entry.Message)
|
||||
}
|
||||
details := entry.Fields["details"].(map[string]any)
|
||||
if strings.Contains(details["associated_number"].(string), "447700900456") {
|
||||
t.Fatalf("nested field leaked: %#v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubSubscription(t *testing.T) {
|
||||
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
entries, cancel := hub.Subscribe(1)
|
||||
@@ -47,3 +95,29 @@ func TestHubSubscription(t *testing.T) {
|
||||
t.Fatal("timed out waiting for log entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubClearDropsHistoryAndQueuedEntries(t *testing.T) {
|
||||
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
entries, cancel := hub.Subscribe(4)
|
||||
defer cancel()
|
||||
logger := slog.New(hub)
|
||||
logger.Info("before clear")
|
||||
hub.Clear()
|
||||
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||
t.Fatalf("history after Clear = %#v", history)
|
||||
}
|
||||
select {
|
||||
case entry := <-entries:
|
||||
t.Fatalf("queued entry survived Clear: %#v", entry)
|
||||
default:
|
||||
}
|
||||
logger.Info("after clear")
|
||||
select {
|
||||
case entry := <-entries:
|
||||
if entry.Message != "after clear" {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscriber did not remain active after Clear")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package loghub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
sipIdentityPattern = regexp.MustCompile(`(?i)\b(sips?|tel):([^@;>,\s]+)(@[^;>,\s]+)?`)
|
||||
internationalPhonePattern = regexp.MustCompile(`(?:\+|00)[0-9][0-9 ()-]{5,}[0-9]`)
|
||||
longDigitsPattern = regexp.MustCompile(`\b[0-9]{7,22}\b`)
|
||||
labeledIdentityPattern = regexp.MustCompile(`(?i)\b(iccid|imsi|msisdn|imei|eid)\s*([=:])\s*([a-z0-9+_-]{7,})`)
|
||||
)
|
||||
|
||||
// IsHTTPAccessEntry identifies legacy request-traffic entries. Access traffic
|
||||
// is intentionally excluded from the user diagnostic log surface.
|
||||
func IsHTTPAccessEntry(entry Entry) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(entry.Message), "http request") {
|
||||
return true
|
||||
}
|
||||
category, _ := entry.Fields["category"].(string)
|
||||
return strings.EqualFold(strings.TrimSpace(category), "http_access")
|
||||
}
|
||||
|
||||
// SanitizeEntry also protects records that were persisted by an older build
|
||||
// before central redaction was introduced.
|
||||
func SanitizeEntry(entry Entry) Entry {
|
||||
entry.Message = RedactString(entry.Message)
|
||||
entry.Caller = RedactString(entry.Caller)
|
||||
if entry.Fields != nil {
|
||||
entry.Fields = sanitizeMap(entry.Fields)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// RedactString masks common telecom identities while retaining enough of the
|
||||
// suffix to correlate repeated events in an exported diagnostic log.
|
||||
func RedactString(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
value = labeledIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := labeledIdentityPattern.FindStringSubmatch(match)
|
||||
return parts[1] + parts[2] + maskToken(parts[3])
|
||||
})
|
||||
value = sipIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := sipIdentityPattern.FindStringSubmatch(match)
|
||||
domain := parts[3]
|
||||
return parts[1] + ":" + maskToken(parts[2]) + domain
|
||||
})
|
||||
value = internationalPhonePattern.ReplaceAllStringFunc(value, maskToken)
|
||||
return longDigitsPattern.ReplaceAllStringFunc(value, maskToken)
|
||||
}
|
||||
|
||||
func sanitizeRecord(record slog.Record) slog.Record {
|
||||
clean := slog.NewRecord(record.Time, record.Level, RedactString(record.Message), record.PC)
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
clean.AddAttrs(sanitizeAttr(attr))
|
||||
return true
|
||||
})
|
||||
return clean
|
||||
}
|
||||
|
||||
func sanitizeAttrs(attrs []slog.Attr) []slog.Attr {
|
||||
clean := make([]slog.Attr, 0, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
clean = append(clean, sanitizeAttr(attr))
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func sanitizeAttr(attr slog.Attr) slog.Attr {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
return attr
|
||||
}
|
||||
if sensitiveKey(attr.Key) {
|
||||
return slog.String(attr.Key, maskToken(valueText(attr.Value.Any())))
|
||||
}
|
||||
if attr.Value.Kind() == slog.KindGroup {
|
||||
children := attr.Value.Group()
|
||||
return slog.Group(attr.Key, attrsToAny(sanitizeAttrs(children))...)
|
||||
}
|
||||
switch attr.Value.Kind() {
|
||||
case slog.KindString:
|
||||
return slog.String(attr.Key, RedactString(attr.Value.String()))
|
||||
case slog.KindAny:
|
||||
return slog.Any(attr.Key, sanitizeAny(attr.Value.Any(), attr.Key))
|
||||
default:
|
||||
return attr
|
||||
}
|
||||
}
|
||||
|
||||
func attrsToAny(attrs []slog.Attr) []any {
|
||||
values := make([]any, len(attrs))
|
||||
for index := range attrs {
|
||||
values[index] = attrs[index]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func sanitizeAny(value any, key string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if sensitiveKey(key) {
|
||||
return maskToken(valueText(value))
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case error:
|
||||
return RedactString(typed.Error())
|
||||
case string:
|
||||
return RedactString(typed)
|
||||
case []byte:
|
||||
return RedactString(string(typed))
|
||||
case json.RawMessage:
|
||||
var decoded any
|
||||
if json.Unmarshal(typed, &decoded) == nil {
|
||||
return sanitizeAny(decoded, key)
|
||||
}
|
||||
return RedactString(string(typed))
|
||||
case map[string]any:
|
||||
return sanitizeMap(typed)
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index := range typed {
|
||||
result[index] = sanitizeAny(typed[index], key)
|
||||
}
|
||||
return result
|
||||
case time.Time, time.Duration:
|
||||
return value
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(value)
|
||||
if rv.IsValid() && (rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct || rv.Kind() == reflect.Pointer) {
|
||||
if raw, err := json.Marshal(value); err == nil {
|
||||
var decoded any
|
||||
if json.Unmarshal(raw, &decoded) == nil {
|
||||
return sanitizeAny(decoded, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if stringer, ok := value.(fmt.Stringer); ok {
|
||||
return RedactString(stringer.String())
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizeMap(source map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(source))
|
||||
for key, value := range source {
|
||||
result[key] = sanitizeAny(value, key)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sensitiveKey(key string) bool {
|
||||
normalized := strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, key)
|
||||
if strings.Contains(normalized, "password") || strings.Contains(normalized, "passwd") ||
|
||||
strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") ||
|
||||
strings.Contains(normalized, "cookie") || strings.Contains(normalized, "authorization") ||
|
||||
strings.Contains(normalized, "privateidentity") || strings.Contains(normalized, "publicidentity") ||
|
||||
strings.Contains(normalized, "associatednumber") || strings.Contains(normalized, "sipuri") {
|
||||
return true
|
||||
}
|
||||
switch normalized {
|
||||
case "iccid", "imsi", "imei", "eid", "supi", "suci", "msisdn", "phone", "phonenumber",
|
||||
"number", "caller", "called", "callee", "recipient", "peer", "from", "to":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func valueText(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if err, ok := value.(error); ok {
|
||||
return err.Error()
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func maskToken(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
runes := []rune(value)
|
||||
digits := make([]rune, 0, 4)
|
||||
for index := len(runes) - 1; index >= 0 && len(digits) < 4; index-- {
|
||||
if unicode.IsDigit(runes[index]) {
|
||||
digits = append(digits, runes[index])
|
||||
}
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
|
||||
digits[left], digits[right] = digits[right], digits[left]
|
||||
}
|
||||
return "[REDACTED:" + string(digits) + "]"
|
||||
}
|
||||
@@ -14,7 +14,13 @@ import (
|
||||
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
|
||||
DevRoot string
|
||||
@@ -83,7 +89,15 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
||||
continue
|
||||
// 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]
|
||||
@@ -142,11 +156,19 @@ 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 bound QMI interface proves the modem is alive, but the snapshot,
|
||||
// SMS, USSD and eSIM (AT+CSIM) paths all require an AT port. A missing
|
||||
// ttyUSB/ttyACM node almost always means the option/qcserial driver
|
||||
// does not claim the serial interfaces (often a missing PID in its
|
||||
// device-ID table), not that the module lacks an AT interface.
|
||||
// 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)
|
||||
@@ -168,6 +190,15 @@ func IsDJI4GUSB(vendorID, productID string) bool {
|
||||
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
|
||||
|
||||
@@ -444,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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,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{
|
||||
@@ -48,7 +78,7 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -21,6 +22,20 @@ func (s *Server) recordAudit(
|
||||
outcome string,
|
||||
remoteAddr string,
|
||||
) {
|
||||
level := slog.LevelInfo
|
||||
if !strings.EqualFold(strings.TrimSpace(outcome), "success") {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Log(ctx, level, "user operation",
|
||||
"category", auditLogCategory(action),
|
||||
"event", action,
|
||||
"actor", actor,
|
||||
"entity_type", entityType,
|
||||
"entity_id", entityID,
|
||||
"outcome", outcome,
|
||||
)
|
||||
}
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
@@ -34,7 +49,24 @@ func (s *Server) recordAudit(
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("write audit event failed", "action", action, "error", err)
|
||||
s.logger.Warn("write audit event failed", "category", "system", "action", action, "raw_error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func auditLogCategory(action string) string {
|
||||
action = strings.ToLower(strings.TrimSpace(action))
|
||||
switch {
|
||||
case strings.Contains(action, ".sms") || strings.HasPrefix(action, "sms."):
|
||||
return "sms"
|
||||
case strings.Contains(action, ".call") || strings.HasPrefix(action, "call."):
|
||||
return "call"
|
||||
case strings.Contains(action, "vowifi") || strings.Contains(action, "ims"):
|
||||
return "vowifi"
|
||||
case strings.Contains(action, "device") || strings.Contains(action, "esim") ||
|
||||
strings.Contains(action, ".at.") || strings.Contains(action, ".ussd"):
|
||||
return "hardware"
|
||||
default:
|
||||
return "operation"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Warn("VoWiFi call operation failed",
|
||||
"category", "call", "event", "call."+action,
|
||||
"device_id", config.ID, "number", number, "call_id", callID,
|
||||
"transport", transport, "raw_error", err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
|
||||
return true
|
||||
}
|
||||
@@ -156,6 +161,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
|
||||
return true
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
|
||||
s.logger.Warn("cellular call operation rejected",
|
||||
"category", "call", "event", "call."+action,
|
||||
"device_id", config.ID, "number", number, "transport", transport,
|
||||
"modem_final", response.Final, "raw_response", response.Text(),
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
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()
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Info("incoming call detected",
|
||||
"category", "call",
|
||||
"event", "call.incoming",
|
||||
"device_id", notification.DeviceID,
|
||||
"caller", notification.Caller,
|
||||
"called", notification.Called,
|
||||
"transport", notification.Environment,
|
||||
)
|
||||
}
|
||||
|
||||
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", "category", "call", "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", "category", "call", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "raw_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 {
|
||||
if isIncomingVoiceCLCC(call) {
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isIncomingVoiceCLCC(call map[string]any) bool {
|
||||
direction, _ := call["direction"].(int)
|
||||
state, _ := call["state"].(int)
|
||||
mode, _ := call["mode"].(int)
|
||||
// direction 1 = incoming (Mobile Terminated)
|
||||
// mode 0 = voice; some modems also expose packet-data sessions as CLCC mode 1
|
||||
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
|
||||
return direction == 1 && mode == 0 && (state == 4 || state == 5 || state == 0 || state == 3)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
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 TestIncomingVoiceCLCCIgnoresDataSessions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
call map[string]any
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "incoming voice ringing",
|
||||
call: map[string]any{"direction": 1, "state": 4, "mode": 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "incoming voice active",
|
||||
call: map[string]any{"direction": 1, "state": 0, "mode": 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "incoming packet data active",
|
||||
call: map[string]any{"direction": 1, "state": 0, "mode": 1},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "outgoing voice alerting",
|
||||
call: map[string]any{"direction": 0, "state": 3, "mode": 0},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isIncomingVoiceCLCC(test.call); got != test.want {
|
||||
t.Fatalf("isIncomingVoiceCLCC() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// EC20/EC25 firmware may expose an active packet-data session in CLCC.
|
||||
// It must not be treated as an incoming voice call.
|
||||
dataCalls := parseCLCC(modem.Response{
|
||||
Lines: []string{`+CLCC: 1,1,0,1,0,"",128`},
|
||||
Final: "OK",
|
||||
})
|
||||
if len(dataCalls) != 1 {
|
||||
t.Fatalf("parseCLCC() returned %d data calls, want 1", len(dataCalls))
|
||||
}
|
||||
if isIncomingVoiceCLCC(dataCalls[0]) {
|
||||
t.Fatal("active packet-data CLCC record was treated as an incoming voice call")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+138
-16
@@ -581,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":
|
||||
@@ -1002,6 +1002,11 @@ func (s *Server) handleVoWiFiReconnect(
|
||||
}
|
||||
|
||||
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
|
||||
s.logger.Warn("VoWiFi operation failed",
|
||||
"category", "vowifi",
|
||||
"event", "vowifi.operation_failed",
|
||||
"raw_error", err,
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, vowifiruntime.ErrNotRegistered):
|
||||
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime")
|
||||
@@ -1012,7 +1017,6 @@ func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, vowifi.ErrNotRunning):
|
||||
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
|
||||
default:
|
||||
s.logger.Warn("VoWiFi action rejected", "error", err)
|
||||
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1045,13 +1049,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
|
||||
}
|
||||
@@ -1069,6 +1074,13 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
text += "\n"
|
||||
}
|
||||
text += commandErr.Final
|
||||
s.logger.Warn("AT command rejected by modem",
|
||||
"category", "hardware",
|
||||
"event", "hardware.at_rejected",
|
||||
"device_id", id,
|
||||
"modem_final", commandErr.Final,
|
||||
"raw_response", text,
|
||||
)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"response": text,
|
||||
@@ -1100,7 +1112,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")
|
||||
@@ -1108,6 +1120,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"`,
|
||||
@@ -1138,7 +1153,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
|
||||
}
|
||||
@@ -1152,21 +1194,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
|
||||
@@ -1407,6 +1520,11 @@ func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool
|
||||
}
|
||||
|
||||
func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||
s.logger.Warn("hardware operation failed",
|
||||
"category", "hardware",
|
||||
"event", "hardware.operation_failed",
|
||||
"raw_error", device.HardwareErrorDetail(err),
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, device.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present")
|
||||
@@ -1445,9 +1563,6 @@ 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:
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1636,7 +1751,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
|
||||
|
||||
@@ -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 {
|
||||
@@ -442,7 +580,7 @@ func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
|
||||
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
||||
SequenceNumber: 12,
|
||||
Event: "delete",
|
||||
ICCID: "89441000400128014257",
|
||||
ICCID: "8944100000000000001",
|
||||
Address: "rsp.example.com",
|
||||
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
||||
CanRetry: true,
|
||||
|
||||
@@ -40,12 +40,12 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
||||
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: "stopping",
|
||||
ICCID: "89441000400128014257",
|
||||
IMSI: "234159608751160",
|
||||
ICCID: "8944100000000000001",
|
||||
IMSI: "234150000000001",
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
SMSReady: true,
|
||||
LocalPhone: "+447386083638",
|
||||
LocalPhone: "+447700900123",
|
||||
PhoneNumberSource: "ims_p_associated_uri",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
@@ -60,7 +60,7 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
||||
if got["vowifi_active"] != false {
|
||||
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
||||
}
|
||||
if got["local_phone"] == "+447386083638" {
|
||||
if got["local_phone"] == "+447700900123" {
|
||||
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
|
||||
}
|
||||
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
||||
@@ -134,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"},
|
||||
@@ -146,7 +169,7 @@ func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
||||
}
|
||||
for _, snapshot := range []*device.Snapshot{
|
||||
{SIMStatus: "pin_required"},
|
||||
{ICCID: "89441000400128014257"},
|
||||
{ICCID: "8944100000000000001"},
|
||||
{SIMReady: true},
|
||||
} {
|
||||
if !snapshotHasSIM(snapshot) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -152,7 +152,24 @@ func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
if r.Method == http.MethodDelete {
|
||||
clearedAt := time.Now().UTC()
|
||||
if s.logs != nil {
|
||||
s.logs.Clear()
|
||||
}
|
||||
deleted, err := s.store.ClearLogEvents(r.Context(), clearedAt)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{"cleared": true, "deleted": deleted},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
|
||||
@@ -170,7 +187,9 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
||||
// backs the live stream).
|
||||
entries := []loghub.Entry{}
|
||||
if s.store != nil {
|
||||
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit})
|
||||
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{
|
||||
Limit: limit, ExcludeMessage: "http request",
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -179,11 +198,17 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if storedLogLevel(event.Level) < minimum {
|
||||
continue
|
||||
}
|
||||
entry := storedLogToEntry(event)
|
||||
entry := loghub.SanitizeEntry(storedLogToEntry(event))
|
||||
if loghub.IsHTTPAccessEntry(entry) {
|
||||
continue
|
||||
}
|
||||
if search != "" && !storedLogContains(entry, search) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if len(entries) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
// ListLogEvents is newest-first; present chronologically.
|
||||
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
||||
@@ -283,7 +308,8 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if logLevel(entry.Level) < minimum {
|
||||
entry = loghub.SanitizeEntry(entry)
|
||||
if loghub.IsHTTPAccessEntry(entry) || logLevel(entry.Level) < minimum {
|
||||
continue
|
||||
}
|
||||
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
|
||||
@@ -308,7 +334,7 @@ func logLevel(value string) slog.Level {
|
||||
return slog.LevelError
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "debug":
|
||||
case "debug", "all", "":
|
||||
return slog.LevelDebug
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const maxLarkPayloadBytes = 20 << 10
|
||||
@@ -128,7 +130,8 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok {
|
||||
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" {
|
||||
@@ -140,7 +143,11 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
||||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
||||
return nil, errors.New("Lark group bot webhook path is invalid")
|
||||
}
|
||||
return parsed, nil
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: canonicalHost,
|
||||
Path: prefix + url.PathEscape(token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
@@ -192,9 +199,15 @@ func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValu
|
||||
}
|
||||
|
||||
func validateLarkNotificationConfig(config map[string]any) error {
|
||||
if configString(config, "url") == "" {
|
||||
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")
|
||||
@@ -205,12 +218,10 @@ func validateLarkNotificationConfig(config map[string]any) error {
|
||||
return errors.New("lark.secret is required when signing is enabled")
|
||||
}
|
||||
}
|
||||
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
|
||||
if err != nil {
|
||||
if _, err := renderLarkPayload(template, larkTestValues(time.Now())); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func larkSigningSecret(config map[string]any) string {
|
||||
@@ -222,9 +233,6 @@ func larkSigningSecret(config map[string]any) string {
|
||||
}
|
||||
|
||||
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
|
||||
if err := validateLarkNotificationConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -251,6 +259,8 @@ func postLarkNotification(ctx context.Context, client *http.Client, endpoint str
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestHandleLogHistoryDeleteClearsMemoryAndDatabase(t *testing.T) {
|
||||
server := newSettingsTestServer(t)
|
||||
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
server.logs = hub
|
||||
server.logger = slog.New(hub)
|
||||
server.logger.Info("memory log")
|
||||
if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{
|
||||
Level: "info", Message: "persisted log",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil)
|
||||
server.handleLogHistory(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||
t.Fatalf("memory history after clear = %#v", history)
|
||||
}
|
||||
count, err := server.store.CountLogEvents(context.Background())
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("persisted count after clear = %d, %v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,17 @@ func parseLoggingConfig(config loggingConfig) (loggingConfig, error) {
|
||||
if config.Count < 1 {
|
||||
config.Count = 10000
|
||||
}
|
||||
if config.Count > store.MaxLogEvents {
|
||||
config.Count = store.MaxLogEvents
|
||||
}
|
||||
if config.Days < 1 {
|
||||
config.Days = 30
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// loadLoggingConfig reads the persisted retention policy, defaulting to unlimited.
|
||||
// loadLoggingConfig reads the persisted retention policy. "unlimited" means
|
||||
// no user-selected limit below the global 10,000-row hard ceiling.
|
||||
func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
|
||||
config := defaultLoggingConfig()
|
||||
setting, err := s.store.AppSetting(ctx, loggingSettingKey)
|
||||
@@ -64,13 +68,17 @@ func (s *Server) applyLogRetention(ctx context.Context) error {
|
||||
switch config.Mode {
|
||||
case "days":
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour)
|
||||
_, err := s.store.PruneLogEvents(ctx, cutoff)
|
||||
if _, err := s.store.PruneLogEvents(ctx, cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
|
||||
return err
|
||||
case "count":
|
||||
_, err := s.store.PruneLogEventsToCount(ctx, config.Count)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +124,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"count": config.Count,
|
||||
"days": config.Days,
|
||||
"stored_logs": stored,
|
||||
"max_logs": store.MaxLogEvents,
|
||||
},
|
||||
})
|
||||
case http.MethodPut:
|
||||
@@ -152,6 +161,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"count": config.Count,
|
||||
"days": config.Days,
|
||||
"stored_logs": stored,
|
||||
"max_logs": store.MaxLogEvents,
|
||||
},
|
||||
})
|
||||
default:
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const testProfileICCID = "89441000400128014257"
|
||||
const testProfileICCID = "8944100000000000001"
|
||||
|
||||
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
||||
t.Helper()
|
||||
@@ -51,7 +51,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||
"upstream_proxy_id":"route-1",
|
||||
"bindings":[
|
||||
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
|
||||
{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Vodafone UK","state_text":"Enabled"},
|
||||
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
|
||||
]
|
||||
}`)
|
||||
@@ -66,7 +66,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
||||
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
|
||||
}
|
||||
|
||||
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
|
||||
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["8944100000000000001","89104100000028106378"]}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -80,11 +80,11 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
||||
|
||||
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
||||
server, database, _ := newProfileBindingTestServer(t)
|
||||
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
||||
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
||||
}
|
||||
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
||||
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
|
||||
if second.Code != http.StatusConflict {
|
||||
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
||||
}
|
||||
|
||||
@@ -199,6 +199,16 @@ func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggingCountIsClampedToHardLimit(t *testing.T) {
|
||||
config, err := parseLoggingConfig(loggingConfig{Mode: "count", Count: store.MaxLogEvents + 500})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Count != store.MaxLogEvents {
|
||||
t.Fatalf("count = %d, want %d", config.Count, store.MaxLogEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLockoutViaHTTP(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
for i := 0; i < 4; i++ {
|
||||
|
||||
+49
-14
@@ -57,6 +57,7 @@ type Server struct {
|
||||
auth *auth.Service
|
||||
devices DeviceController
|
||||
vowifi VoWiFiController
|
||||
ussdSessions ussdSessionStore
|
||||
logs *loghub.Hub
|
||||
assets fs.FS
|
||||
indexHTML []byte
|
||||
@@ -117,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,
|
||||
@@ -158,7 +160,7 @@ func New(options Options) (*Server, error) {
|
||||
mux.HandleFunc("/", server.handleSPA)
|
||||
|
||||
server.handler = server.recoverPanics(
|
||||
server.securityHeaders(server.accessControl(server.logRequests(mux))),
|
||||
server.securityHeaders(server.accessControl(server.logUserOperation(mux))),
|
||||
)
|
||||
return server, nil
|
||||
}
|
||||
@@ -555,16 +557,14 @@ func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool
|
||||
return false
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
type operationStatusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
func (w *operationStatusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
func (w *statusWriter) WriteHeader(status int) {
|
||||
func (w *operationStatusWriter) WriteHeader(status int) {
|
||||
if w.status != 0 {
|
||||
return
|
||||
}
|
||||
@@ -572,25 +572,60 @@ func (w *statusWriter) WriteHeader(status int) {
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
// logUserOperation records state-changing API actions, not request traffic.
|
||||
// GET/HEAD polling, assets, health checks and the live log stream are never
|
||||
// emitted, keeping the diagnostic page focused on actions a user initiated.
|
||||
func (s *Server) logUserOperation(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
writer := &statusWriter{ResponseWriter: w}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||
r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/auth/") || strings.HasPrefix(r.URL.Path, "/api/logs/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
writer := &operationStatusWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(writer, r)
|
||||
status := writer.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
s.logger.Info(
|
||||
"http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
level := slog.LevelInfo
|
||||
outcome := "success"
|
||||
message := "user operation completed"
|
||||
if status >= http.StatusBadRequest {
|
||||
level = slog.LevelWarn
|
||||
outcome = "failed"
|
||||
message = "user operation failed"
|
||||
}
|
||||
s.logger.Log(r.Context(), level, message,
|
||||
"category", operationPathCategory(r.URL.Path),
|
||||
"event", "user.operation",
|
||||
"operation", strings.TrimPrefix(r.URL.Path, "/api/"),
|
||||
"outcome", outcome,
|
||||
"status", status,
|
||||
"duration", time.Since(startedAt),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func operationPathCategory(path string) string {
|
||||
path = strings.ToLower(path)
|
||||
switch {
|
||||
case strings.Contains(path, "/sms"):
|
||||
return "sms"
|
||||
case strings.Contains(path, "/call"):
|
||||
return "call"
|
||||
case strings.Contains(path, "/vowifi") || strings.Contains(path, "/ims"):
|
||||
return "vowifi"
|
||||
case strings.Contains(path, "/network") || strings.Contains(path, "/operator"):
|
||||
return "network"
|
||||
case strings.Contains(path, "/device") || strings.Contains(path, "/esim") ||
|
||||
strings.Contains(path, "/ussd") || strings.Contains(path, "/at"):
|
||||
return "hardware"
|
||||
default:
|
||||
return "operation"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
@@ -18,9 +19,36 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestUserOperationLoggerExcludesReadTraffic(t *testing.T) {
|
||||
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
server := &Server{logger: slog.New(hub)}
|
||||
handler := server.logUserOperation(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/devices", nil))
|
||||
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||
t.Fatalf("GET traffic produced diagnostic logs: %#v", history)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPatch, "/api/devices/dev1/network", nil))
|
||||
history := hub.History(10, slog.LevelDebug, "")
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("mutation log count = %d, want 1", len(history))
|
||||
}
|
||||
if history[0].Message != "user operation completed" || history[0].Fields["category"] != "network" {
|
||||
t.Fatalf("mutation log = %#v", history[0])
|
||||
}
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil))
|
||||
if history = hub.History(10, slog.LevelDebug, ""); len(history) != 1 {
|
||||
t.Fatalf("log clear endpoint produced an operation log: %#v", history)
|
||||
}
|
||||
}
|
||||
|
||||
type testApplication struct {
|
||||
server *httptest.Server
|
||||
client *http.Client
|
||||
|
||||
@@ -295,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)
|
||||
}
|
||||
}
|
||||
@@ -883,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)
|
||||
@@ -989,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
|
||||
}
|
||||
@@ -999,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 {
|
||||
|
||||
@@ -420,6 +420,11 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
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`,
|
||||
@@ -994,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)
|
||||
|
||||
@@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
for _, message := range messages {
|
||||
if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
|
||||
message.Read = true
|
||||
_, _ = s.store.SaveSMSMessage(r.Context(), message)
|
||||
unreadIDs := make([]int64, 0, len(messages))
|
||||
for i := range messages {
|
||||
if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") {
|
||||
messages[i].Read = true
|
||||
unreadIDs = append(unreadIDs, messages[i].ID)
|
||||
}
|
||||
}
|
||||
if len(unreadIDs) > 0 {
|
||||
if markErr := s.store.MarkSMSMessagesRead(r.Context(), unreadIDs); markErr != nil {
|
||||
s.logger.Warn("mark SMS messages read failed", "error", markErr)
|
||||
}
|
||||
}
|
||||
reverseSMS(messages)
|
||||
@@ -363,16 +369,26 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
if sendErr != nil {
|
||||
data["retry_safe"] = false
|
||||
if result.PartsAccepted > 0 {
|
||||
s.logger.Warn("multipart SMS was only partially accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", request.DeviceID, "peer", request.Phone,
|
||||
"transport", "cellular_at", "parts_attempted", result.PartsAttempted,
|
||||
"parts_accepted", result.PartsAccepted, "raw_error", sendErr,
|
||||
)
|
||||
data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||
return
|
||||
}
|
||||
s.logger.Warn(
|
||||
"SMS submission failed after modem interaction",
|
||||
"category", "sms",
|
||||
"event", "sms.submission",
|
||||
"device_id", request.DeviceID,
|
||||
"peer", request.Phone,
|
||||
"transport", "cellular_at",
|
||||
"parts_attempted", result.PartsAttempted,
|
||||
"parts_accepted", result.PartsAccepted,
|
||||
"error", sendErr,
|
||||
"raw_error", sendErr,
|
||||
)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||
"error": apiError{
|
||||
@@ -384,6 +400,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !result.AllPartsAccepted {
|
||||
s.logger.Warn("SMS submission was not confirmed",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", request.DeviceID, "peer", request.Phone,
|
||||
"transport", "cellular_at", "modem_final", result.ModemFinal,
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||
"error": apiError{
|
||||
Code: "sms_submission_unconfirmed",
|
||||
@@ -393,6 +415,11 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
s.logger.Info("SMS submission accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", request.DeviceID, "peer", request.Phone,
|
||||
"transport", "cellular_at", "parts", result.PartsAccepted,
|
||||
)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||
}
|
||||
|
||||
@@ -469,6 +496,12 @@ func (s *Server) writeIMSSMSSendResult(
|
||||
"outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
|
||||
}
|
||||
if sendErr != nil {
|
||||
s.logger.Warn("IMS SMS submission failed",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
"raw_error", sendErr,
|
||||
)
|
||||
data["retry_safe"] = false
|
||||
data["warning"] = sendErr.Error()
|
||||
if result.PartsAccepted == 0 {
|
||||
@@ -483,6 +516,11 @@ func (s *Server) writeIMSSMSSendResult(
|
||||
}
|
||||
}
|
||||
if !result.AllPartsAccepted && result.PartsAccepted == 0 {
|
||||
s.logger.Warn("IMS SMS submission was not confirmed",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||
"error": apiError{
|
||||
Code: "ims_sms_submission_unconfirmed",
|
||||
@@ -492,6 +530,19 @@ func (s *Server) writeIMSSMSSendResult(
|
||||
})
|
||||
return
|
||||
}
|
||||
if result.AllPartsAccepted {
|
||||
s.logger.Info("IMS SMS submission accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts", result.PartsAccepted,
|
||||
)
|
||||
} else {
|
||||
s.logger.Warn("multipart IMS SMS was only partially accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||
}
|
||||
|
||||
@@ -647,7 +698,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
"delivery_status": message.DeliveryStatus,
|
||||
"data_coding_scheme": message.DataCodingScheme,
|
||||
})
|
||||
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
saved, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: messageID,
|
||||
DeviceID: config.ID,
|
||||
ModemIMEI: modemIMEI,
|
||||
@@ -664,7 +715,14 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
Extra: extra,
|
||||
})
|
||||
if saveErr != nil {
|
||||
s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr)
|
||||
s.logger.Warn("persist modem SMS failed", "category", "sms", "device_id", config.ID, "raw_error", saveErr)
|
||||
} else if saved.Direction == "inbound" && saved.CreatedAt.Unix() == saved.UpdatedAt.Unix() {
|
||||
s.logger.Info("cellular SMS received",
|
||||
"category", "sms", "event", "sms.received",
|
||||
"device_id", config.ID, "peer", saved.Peer,
|
||||
"transport", "cellular_at", "encoding", message.Encoding,
|
||||
"parts", saved.PartsTotal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -764,6 +822,6 @@ func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("database operation failed", "error", err)
|
||||
s.logger.Error("database operation failed", "category", "system", "event", "store.operation_failed", "raw_error", err)
|
||||
writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
|
||||
}
|
||||
|
||||
@@ -236,10 +236,10 @@ func (bot *telegramBot) getUpdates(
|
||||
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
||||
if callback := update.CallbackQuery; callback != nil {
|
||||
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
|
||||
_ = bot.answerCallback(ctx, config, callback.ID, "无权限")
|
||||
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "无权限") }()
|
||||
return
|
||||
}
|
||||
_ = bot.answerCallback(ctx, config, callback.ID, "")
|
||||
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "") }()
|
||||
bot.handleCallback(ctx, config, callback)
|
||||
return
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
||||
}
|
||||
state := &vowifi.State{
|
||||
ICCID: "previous-card",
|
||||
PhoneNumber: "+447386083638",
|
||||
PhoneNumber: "+447700900123",
|
||||
}
|
||||
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
||||
t.Fatalf("stale or placeholder number leaked as %q", got)
|
||||
@@ -135,10 +135,10 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
||||
}
|
||||
|
||||
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
||||
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||
if got := telegramHomeCarrier("234330000000001"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||
t.Fatalf("home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
||||
if got := telegramHomeCarrier("454000000000001", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
||||
t.Fatalf("profile brand overrode home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
||||
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
||||
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
||||
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
||||
VALUES ('known', '89441000400128014257', 100);
|
||||
VALUES ('known', '8944100000000000001', 100);
|
||||
PRAGMA user_version = 11;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -138,7 +138,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
||||
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
|
||||
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
||||
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
||||
}
|
||||
@@ -579,6 +579,20 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
|
||||
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
||||
t.Fatalf("thread should be read: %+v", contacts)
|
||||
}
|
||||
|
||||
// A subsequent periodic modem AT sync with raw unread state must not revert is_read back to 0.
|
||||
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
|
||||
Peer: "10086", Direction: "inbound", Body: "第一条(完整)",
|
||||
Timestamp: base, Status: "received", Read: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
|
||||
if err != nil || len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
||||
t.Fatalf("thread read state must survive modem rescan: %+v", contacts)
|
||||
}
|
||||
|
||||
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
|
||||
if err != nil || deleted != 2 {
|
||||
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
|
||||
@@ -789,11 +803,11 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
||||
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
||||
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
||||
DeviceID: "ec20-1", ICCID: "8944100000000000001", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
||||
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
|
||||
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
||||
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
||||
}
|
||||
@@ -803,7 +817,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
||||
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
||||
}
|
||||
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
|
||||
if _, err := database.DeviceProxyBinding(ctx, "8944100000000000001"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1017,15 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
|
||||
t.Fatalf("log filter result = %+v, %v", logs, err)
|
||||
}
|
||||
if _, err := database.AppendLogEvent(ctx, LogEvent{
|
||||
Time: recent, Level: "info", Message: " HTTP REQUEST ",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logs, err = database.ListLogEvents(ctx, LogFilter{Level: "info", ExcludeMessage: "http request"})
|
||||
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
|
||||
t.Fatalf("excluded log filter result = %+v, %v", logs, err)
|
||||
}
|
||||
auditDeleted, logDeleted, err := database.PruneEvents(
|
||||
ctx,
|
||||
old.Add(time.Minute),
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxLogEvents is the hard storage ceiling. Every new row beyond this limit
|
||||
// replaces the oldest row regardless of the optional, stricter retention rule.
|
||||
const MaxLogEvents = 10000
|
||||
|
||||
func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
|
||||
value.Action = strings.TrimSpace(value.Action)
|
||||
if value.Action == "" {
|
||||
@@ -123,6 +127,8 @@ func auditEvent(row rowScanner) (AuditEvent, error) {
|
||||
}
|
||||
|
||||
func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) {
|
||||
s.logMu.Lock()
|
||||
defer s.logMu.Unlock()
|
||||
value.Level = strings.ToLower(strings.TrimSpace(value.Level))
|
||||
if value.Level == "" {
|
||||
return LogEvent{}, errors.New("log level is required")
|
||||
@@ -137,7 +143,18 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
|
||||
if value.Time.IsZero() {
|
||||
value.Time = time.Now().UTC()
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
value.Fields = fields
|
||||
if !s.logClearedAt.IsZero() && !value.Time.After(s.logClearedAt) {
|
||||
// The entry was queued before a user cleared the log. Silently discard it
|
||||
// so an in-flight persistence worker cannot resurrect cleared history.
|
||||
return value, nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return LogEvent{}, fmt.Errorf("begin log append: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO log_events (event_time, level, message, caller, fields_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields))
|
||||
@@ -148,7 +165,17 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
|
||||
if err != nil {
|
||||
return LogEvent{}, fmt.Errorf("read log event id: %w", err)
|
||||
}
|
||||
value.Fields = fields
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM log_events
|
||||
WHERE id <= COALESCE((
|
||||
SELECT id FROM log_events ORDER BY id DESC LIMIT 1 OFFSET ?
|
||||
), 0)
|
||||
`, MaxLogEvents); err != nil {
|
||||
return LogEvent{}, fmt.Errorf("enforce log event limit: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return LogEvent{}, fmt.Errorf("commit log append: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -159,6 +186,10 @@ func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent
|
||||
clauses = append(clauses, `level = ?`)
|
||||
args = append(args, strings.ToLower(filter.Level))
|
||||
}
|
||||
if filter.ExcludeMessage != "" {
|
||||
clauses = append(clauses, `LOWER(TRIM(message)) <> ?`)
|
||||
args = append(args, strings.ToLower(strings.TrimSpace(filter.ExcludeMessage)))
|
||||
}
|
||||
if !filter.Since.IsZero() {
|
||||
clauses = append(clauses, `event_time >= ?`)
|
||||
args = append(args, filter.Since.UTC().Unix())
|
||||
@@ -236,6 +267,29 @@ func (s *Store) CountLogEvents(ctx context.Context) (int64, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ClearLogEvents permanently removes all persisted logs. Entries timestamped
|
||||
// at or before clearedAt are also rejected if they were already queued by the
|
||||
// asynchronous persistence worker.
|
||||
func (s *Store) ClearLogEvents(ctx context.Context, clearedAt time.Time) (int64, error) {
|
||||
s.logMu.Lock()
|
||||
defer s.logMu.Unlock()
|
||||
if clearedAt.IsZero() {
|
||||
clearedAt = time.Now().UTC()
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM log_events`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear log events: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read cleared log count: %w", err)
|
||||
}
|
||||
if clearedAt.After(s.logClearedAt) {
|
||||
s.logClearedAt = clearedAt
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
|
||||
// rest. keep <= 0 deletes everything.
|
||||
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAppendLogEventEnforcesHardLimit(t *testing.T) {
|
||||
database, err := Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if _, err := database.db.ExecContext(context.Background(), `
|
||||
WITH RECURSIVE sequence(value) AS (
|
||||
SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value <= ?
|
||||
)
|
||||
INSERT INTO log_events(event_time, level, message, caller, fields_json)
|
||||
SELECT value, 'info', 'seed-' || value, '', '{}' FROM sequence
|
||||
`, MaxLogEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: "newest", Time: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := database.CountLogEvents(context.Background())
|
||||
if err != nil || count != MaxLogEvents {
|
||||
t.Fatalf("CountLogEvents = %d, %v; want %d", count, err, MaxLogEvents)
|
||||
}
|
||||
logs, err := database.ListLogEvents(context.Background(), LogFilter{Limit: 1})
|
||||
if err != nil || len(logs) != 1 || logs[0].Message != "newest" {
|
||||
t.Fatalf("newest log = %#v, %v", logs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearLogEventsRejectsAlreadyQueuedEntries(t *testing.T) {
|
||||
database, err := Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
cutoff := time.Now().UTC()
|
||||
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: "existing", Time: cutoff.Add(-time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := database.ClearLogEvents(context.Background(), cutoff)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("ClearLogEvents = %d, %v", deleted, err)
|
||||
}
|
||||
late, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: "queued-before-clear", Time: cutoff.Add(-time.Millisecond),
|
||||
})
|
||||
if err != nil || late.ID != 0 {
|
||||
t.Fatalf("old queued append = %+v, %v", late, err)
|
||||
}
|
||||
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: fmt.Sprintf("new-%d", MaxLogEvents), Time: cutoff.Add(time.Millisecond),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := database.CountLogEvents(context.Background())
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("CountLogEvents = %d, %v; want 1", count, err)
|
||||
}
|
||||
}
|
||||
@@ -467,11 +467,12 @@ type LogEvent struct {
|
||||
}
|
||||
|
||||
type LogFilter struct {
|
||||
Level string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
Level string
|
||||
ExcludeMessage string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
type CardPolicy struct {
|
||||
|
||||
+34
-9
@@ -92,15 +92,16 @@ func saveSMSMessage(
|
||||
if mergeErr != nil {
|
||||
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
|
||||
}
|
||||
if existingErr == nil && !changed {
|
||||
// This segment is already folded into the stored row (a periodic modem
|
||||
// rescan redelivers every segment). Leave the row untouched so the
|
||||
// durable id stays put and Telegram does not re-notify.
|
||||
return existing, nil
|
||||
}
|
||||
value.Body = mergedBody
|
||||
extra = mergedExtra
|
||||
if existingErr == nil {
|
||||
if !changed {
|
||||
if value.Read != existing.Read {
|
||||
if _, err := executor.ExecContext(ctx, `UPDATE sms_messages SET is_read = ?, updated_at = ? WHERE id = ?`, boolInt(value.Read), now.Unix(), existing.ID); err != nil {
|
||||
return SMSMessage{}, fmt.Errorf("update concatenated SMS read state: %w", err)
|
||||
}
|
||||
existing.Read = value.Read
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
// A new segment advanced the message. Replace the stale partial row so
|
||||
// the merged row receives a fresh durable id; the Telegram id-cursor
|
||||
// then surfaces the now-more-complete message exactly once. Carry
|
||||
@@ -116,6 +117,8 @@ func saveSMSMessage(
|
||||
value.Timestamp = existing.Timestamp
|
||||
}
|
||||
}
|
||||
value.Body = mergedBody
|
||||
extra = mergedExtra
|
||||
}
|
||||
if value.Timestamp.IsZero() {
|
||||
value.Timestamp = now
|
||||
@@ -171,7 +174,10 @@ func saveSMSMessage(
|
||||
source = excluded.source,
|
||||
parts_total = excluded.parts_total,
|
||||
delivery_state = excluded.delivery_state,
|
||||
is_read = excluded.is_read,
|
||||
is_read = CASE
|
||||
WHEN sms_messages.is_read = 1 THEN 1
|
||||
ELSE excluded.is_read
|
||||
END,
|
||||
extra_json = excluded.extra_json,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
@@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead(
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkSMSMessagesRead(ctx context.Context, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, time.Now().UTC().Unix())
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
query := fmt.Sprintf("UPDATE sms_messages SET is_read = 1, updated_at = ? WHERE id IN (%s) AND is_read = 0", strings.Join(placeholders, ","))
|
||||
_, err := s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark SMS messages read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSMSContacts derives contacts and thread counters from messages. No
|
||||
// duplicated contact/thread table can drift out of sync with message history.
|
||||
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -19,7 +20,9 @@ var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// Store owns the SQLite connection used by the process.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
logMu sync.Mutex
|
||||
logClearedAt time.Time
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
|
||||
@@ -28,25 +28,50 @@ const (
|
||||
// protocol layers consume this common result so their carrier handling cannot
|
||||
// drift into separate MCC/MNC switch statements.
|
||||
type CarrierProfile struct {
|
||||
ID string
|
||||
MatchSource string
|
||||
RouteMCC string
|
||||
RouteMNC string
|
||||
EPDG string
|
||||
IKEProposal string
|
||||
AdvertiseEAPOnly bool
|
||||
IMSTransport string
|
||||
IMSIdentityProfile string
|
||||
IMSRegisterProfile string
|
||||
IMSIPSecEncryption string
|
||||
SMSCenter string
|
||||
PANICountry string
|
||||
PANINode string
|
||||
IMSDialURIScheme string
|
||||
IMSUserEqPhone bool
|
||||
IMSVoiceCodecs []string
|
||||
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
|
||||
PANIEnabled *bool
|
||||
PANICountry string
|
||||
PANINode string
|
||||
IMSUserAgent 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
|
||||
PPreferredIdentity bool
|
||||
PVisitedNetworkID string
|
||||
PAccessNetworkInfo *string
|
||||
CellularNetworkInfo string
|
||||
AcceptContactTags []string
|
||||
}
|
||||
|
||||
const (
|
||||
IMSContactFormatStandard = "standard"
|
||||
IMSContactFormatATT = "att"
|
||||
IMSContactFormatGSMA = "gsma"
|
||||
)
|
||||
|
||||
type carrierProfileDocument struct {
|
||||
Version int `json:"version"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
@@ -88,16 +113,33 @@ type carrierProfileIKE struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
PANIEnabled *bool `json:"pani_enabled,omitempty"`
|
||||
PANICountry string `json:"pani_country,omitempty"`
|
||||
PANINode string `json:"pani_node,omitempty"`
|
||||
UserAgent string `json:"user_agent,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"`
|
||||
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
|
||||
@@ -224,7 +266,11 @@ func carrierProfilesSnapshot() []carrierProfileRule {
|
||||
}
|
||||
|
||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||
capacity := len(rule.MatchAny)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
capacity++
|
||||
}
|
||||
matches := make([]carrierProfileMatch, 0, capacity)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
@@ -279,6 +325,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
return false
|
||||
}
|
||||
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
||||
country != "AUTO" &&
|
||||
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
||||
return false
|
||||
}
|
||||
@@ -292,6 +339,34 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
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 && format != IMSContactFormatGSMA {
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -325,8 +400,8 @@ func decimalString(value string) bool {
|
||||
// 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{
|
||||
func defaultCarrierProfile() CarrierProfile {
|
||||
return CarrierProfile{
|
||||
ID: CarrierProfileStandard,
|
||||
MatchSource: "standard",
|
||||
IKEProposal: IKEProposalModern,
|
||||
@@ -337,6 +412,13 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
IMSDialURIScheme: "tel",
|
||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := defaultCarrierProfile()
|
||||
bestScore := -1
|
||||
for _, rule := range carrierProfilesSnapshot() {
|
||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||
@@ -344,7 +426,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
||||
resolved = applyCarrierProfileRule(defaultCarrierProfile(), rule, source, identity)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -356,7 +438,11 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
||||
bestScore := -1
|
||||
bestSource := ""
|
||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||
capacity := len(rule.MatchAny)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
capacity++
|
||||
}
|
||||
matches := make([]carrierProfileMatch, 0, capacity)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
@@ -374,16 +460,20 @@ func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int
|
||||
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 {
|
||||
if wanted != "" && matchesAny(match.HomePLMNs, func(value string) bool {
|
||||
return canonicalPLMNValue(value) == wanted
|
||||
}) {
|
||||
score += 100
|
||||
sources = append(sources, "hplmn")
|
||||
hasHomePLMNMatch = true
|
||||
} else if identity.HomeMCC != "" && identity.HomeMNC != "" {
|
||||
return 0, "", false
|
||||
}
|
||||
score += 100
|
||||
sources = append(sources, "hplmn")
|
||||
}
|
||||
hasSelectorMatch := false
|
||||
for _, selector := range []struct {
|
||||
name string
|
||||
weight int
|
||||
@@ -393,34 +483,45 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
||||
}{
|
||||
{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},
|
||||
// GID values identify an MVNO/service profile within a host network and
|
||||
// therefore outrank the host issuer's broad ICCID prefix. Otherwise a
|
||||
// home-PLMN+ICCID AT&T rule hides RedPocket/Cricket/etc. even when the SIM
|
||||
// exposes the carrier bundle's exact GID selector.
|
||||
{name: "gid1", weight: 90, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
|
||||
{name: "gid2", weight: 85, 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 {
|
||||
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 || selector.name == "gid1" || selector.name == "gid2" {
|
||||
return 0, "", false
|
||||
}
|
||||
score += selector.weight
|
||||
sources = append(sources, selector.name)
|
||||
}
|
||||
if len(match.SPNs) > 0 {
|
||||
spn := strings.TrimSpace(identity.SPN)
|
||||
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
|
||||
if spn != "" && matchesAny(match.SPNs, func(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||
}) {
|
||||
score += 20
|
||||
sources = append(sources, "spn")
|
||||
hasSelectorMatch = true
|
||||
} else {
|
||||
return 0, "", false
|
||||
}
|
||||
score += 20
|
||||
sources = append(sources, "spn")
|
||||
}
|
||||
if !hasHomePLMNMatch && !hasSelectorMatch {
|
||||
return 0, "", false
|
||||
}
|
||||
return score, strings.Join(sources, "+"), score > 0
|
||||
}
|
||||
@@ -434,11 +535,43 @@ func matchesAny(values []string, match func(string) bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
|
||||
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
|
||||
@@ -459,8 +592,15 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
|
||||
base.IMSIPSecEncryption = value
|
||||
}
|
||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||
if rule.IMS.PANIEnabled != nil {
|
||||
enabled := *rule.IMS.PANIEnabled
|
||||
base.PANIEnabled = &enabled
|
||||
}
|
||||
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
||||
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
||||
if value := strings.TrimSpace(rule.IMS.UserAgent); value != "" {
|
||||
base.IMSUserAgent = value
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
||||
base.IMSDialURIScheme = value
|
||||
}
|
||||
@@ -470,6 +610,47 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -536,19 +717,156 @@ func IsATT310280(identity SIMIdentity) bool {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
switch {
|
||||
case profile.EPDG != "":
|
||||
identity.EPDG = profile.EPDG
|
||||
case profile.RouteMCC != "":
|
||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.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
|
||||
}
|
||||
|
||||
// CountryCodeForMCC returns the ISO 3166-1 alpha-2 country code associated
|
||||
// with an MCC known to the carrier compatibility database.
|
||||
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.
|
||||
@@ -561,6 +879,103 @@ func EPDGDNSClientSubnet(host string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,9 @@
|
||||
package vowifi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iccid string
|
||||
imsi string
|
||||
wantMCC string
|
||||
wantMNC string
|
||||
wantAssigned bool
|
||||
}{
|
||||
{name: "XeSIM Lebara route", iccid: "8944160000000000001", imsi: "204047000000001", wantMCC: "234", wantMNC: "15", wantAssigned: true},
|
||||
{name: "CTExcel initial route", iccid: "8944300000000000001", imsi: "234336000000001", wantMCC: "234", wantMNC: "30", wantAssigned: true},
|
||||
{name: "XeSIM ICCID without matching subscription", iccid: "8944160000000000001", imsi: "204041000000001"},
|
||||
{name: "similar ICCID must not match", iccid: "8944100000000000001", imsi: "204047000000001"},
|
||||
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336000000001"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
|
||||
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
|
||||
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
})
|
||||
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
|
||||
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
|
||||
t.Fatalf("route ePDG = %q", identity.EPDG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
||||
if !IsATT310280(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"}) {
|
||||
t.Fatal("AT&T 310/280 identity was not recognized")
|
||||
}
|
||||
for _, identity := range []SIMIdentity{
|
||||
{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "280"},
|
||||
{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "28"},
|
||||
{IMSI: "310280000000001", HomeMCC: "311", HomeMNC: "280"},
|
||||
} {
|
||||
if IsATT310280(identity) {
|
||||
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
@@ -69,79 +19,155 @@ func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
||||
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||
})
|
||||
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
||||
t.Fatalf("MVNO profile = %#v", profile)
|
||||
}
|
||||
if profile.MatchSource != "hplmn+imsi+iccid" {
|
||||
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
||||
if !strings.Contains(profile.ID, "giffgaff") || profile.MatchSource != "hplmn+gid1" {
|
||||
t.Fatalf("giffgaff profile = %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity SIMIdentity
|
||||
source string
|
||||
}{
|
||||
{
|
||||
name: "Apple GID1 selector",
|
||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
|
||||
source: "hplmn+gid1",
|
||||
},
|
||||
{
|
||||
name: "Android SPN selector",
|
||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"},
|
||||
source: "hplmn+spn",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(test.identity)
|
||||
if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source {
|
||||
t.Fatalf("giffgaff profile = %#v", profile)
|
||||
}
|
||||
if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone {
|
||||
t.Fatalf("giffgaff IMS settings = %#v", profile)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
generic := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10",
|
||||
})
|
||||
if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" {
|
||||
t.Fatalf("generic O2 profile = %#v", generic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) {
|
||||
func TestResolveCarrierProfileGiffgaffIMSHeaders(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||
})
|
||||
if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" {
|
||||
t.Fatalf("EE-hosted profile = %#v", profile)
|
||||
options := profile.IMSRegisterOptions
|
||||
if profile.IMSTransport != "tcp" || options.ContactFormat != IMSContactFormatGSMA {
|
||||
t.Fatalf("giffgaff IMS transport/contact profile = %#v", profile)
|
||||
}
|
||||
if profile.IMSUserAgent != "iOS/18.6.2 iPhone" {
|
||||
t.Fatalf("giffgaff User-Agent = %q", profile.IMSUserAgent)
|
||||
}
|
||||
if options.SupportedHeader != nil || options.AllowHeader != nil {
|
||||
t.Fatalf("giffgaff REGISTER header overrides = supported=%v allow=%v", options.SupportedHeader, options.AllowHeader)
|
||||
}
|
||||
if options.PAccessNetworkInfo != nil {
|
||||
t.Fatalf("giffgaff unexpectedly defines a carrier PANI override = %v", *options.PAccessNetworkInfo)
|
||||
}
|
||||
if profile.PANIEnabled == nil || !*profile.PANIEnabled || profile.PANICountry != "AUTO" {
|
||||
t.Fatalf("giffgaff PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
|
||||
}
|
||||
if len(options.ContactExtraTags) != 2 || options.ContactExtraTags[0] != "+g.3gpp.mid-call" || options.ContactExtraTags[1] != "+g.3gpp.smsip" {
|
||||
t.Fatalf("giffgaff Contact tags = %#v", options.ContactExtraTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
||||
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
||||
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
||||
}
|
||||
func 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 TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
||||
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
||||
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
||||
func TestResolveCarrierProfileRedPocketOutranksBroadATTICCID(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8901410000000000001", IMSI: "310170000000001",
|
||||
HomeMCC: "310", HomeMNC: "170", SPN: "Red Pocket", GID1: "42FFFF",
|
||||
})
|
||||
if profile.ID != "ipcc-redpocket-310170" || profile.MatchSource != "hplmn+gid1" {
|
||||
t.Fatalf("RedPocket profile = %#v", profile)
|
||||
}
|
||||
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
||||
}
|
||||
|
||||
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.PANIEnabled != nil || profile.PANICountry != "" {
|
||||
t.Fatalf("standard PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
|
||||
}
|
||||
if profile.AllowSMSWithoutContactConfirmation {
|
||||
t.Fatal("standard profile should require SMS contact confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMVNOParentNetworkRouting(t *testing.T) {
|
||||
// Giffgaff on O2 UK
|
||||
giffgaff := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||
})
|
||||
if giffgaff.RouteMCC != "234" || giffgaff.RouteMNC != "10" {
|
||||
t.Fatalf("giffgaff Route PLMN = %s-%s, want 234-10", giffgaff.RouteMCC, giffgaff.RouteMNC)
|
||||
}
|
||||
|
||||
// VOXI on Vodafone UK
|
||||
voxi := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI",
|
||||
})
|
||||
if !strings.Contains(voxi.ID, "voxi") || voxi.RouteMCC != "234" || voxi.RouteMNC != "15" {
|
||||
t.Fatalf("VOXI profile = %#v", voxi)
|
||||
}
|
||||
|
||||
// SMARTY on Three UK
|
||||
smarty := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234200000000001", HomeMCC: "234", HomeMNC: "20", SPN: "SMARTY",
|
||||
})
|
||||
if !strings.Contains(smarty.ID, "smarty") || smarty.RouteMCC != "234" || smarty.RouteMNC != "20" {
|
||||
t.Fatalf("SMARTY profile = %#v", smarty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalRoamingProviderResolution(t *testing.T) {
|
||||
// Truphone / BetterRoaming global 90143
|
||||
truphone := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "901430000000001", HomeMCC: "901", HomeMNC: "43",
|
||||
})
|
||||
if (!strings.Contains(truphone.ID, "truphone") && !strings.Contains(truphone.ID, "1global")) || truphone.EPDG != "epdg.eps.truphone.net" {
|
||||
t.Fatalf("Truphone global profile = %#v", truphone)
|
||||
}
|
||||
|
||||
// Jersey Telecom 23450 (eSIM Go / 1GLOBAL / RedteaGO host)
|
||||
jersey := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234500000000001", HomeMCC: "234", HomeMNC: "50",
|
||||
})
|
||||
if !strings.Contains(jersey.ID, "jersey-telecom") || jersey.EPDG != "epdg.epc.mnc050.mcc234.pub.3gppnetwork.org" {
|
||||
t.Fatalf("Jersey Telecom profile = %#v", jersey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCTExcelMVNOResolution(t *testing.T) {
|
||||
ctexcel := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234330000000001",
|
||||
ICCID: "8944300000000000001",
|
||||
SPN: "CTExcel",
|
||||
HomeMCC: "234",
|
||||
HomeMNC: "33",
|
||||
})
|
||||
if ctexcel.ID != "ipcc-ctexcel-23433" {
|
||||
t.Fatalf("CTExcel profile ID = %q, want ipcc-ctexcel-23433", ctexcel.ID)
|
||||
}
|
||||
if ctexcel.IMSDialURIScheme != "sip" || !ctexcel.IMSUserEqPhone {
|
||||
t.Fatalf("CTExcel dial URI scheme = %q, userEqPhone = %v", ctexcel.IMSDialURIScheme, ctexcel.IMSUserEqPhone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,82 @@ func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, erro
|
||||
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 {
|
||||
@@ -425,11 +501,17 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "GID1":
|
||||
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value))
|
||||
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||
match.GID1Prefixes = append(match.GID1Prefixes, trimmed)
|
||||
}
|
||||
case "GID2":
|
||||
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value))
|
||||
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||
match.GID2Prefixes = append(match.GID2Prefixes, trimmed)
|
||||
}
|
||||
case "ICCID":
|
||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, strings.TrimRight(value, "Ff"))
|
||||
if trimmed := strings.TrimRight(value, "Ff"); trimmed != "" {
|
||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, trimmed)
|
||||
}
|
||||
case "SPN":
|
||||
match.SPNs = append(match.SPNs, value)
|
||||
default:
|
||||
@@ -437,16 +519,13 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
}
|
||||
return match, len(parts) > 1, true
|
||||
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))
|
||||
trimmed := strings.TrimRight(value, "F")
|
||||
if trimmed == "" {
|
||||
return value
|
||||
}
|
||||
return trimmed
|
||||
return strings.TrimRight(value, "F")
|
||||
}
|
||||
|
||||
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
||||
@@ -556,6 +635,11 @@ func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ip
|
||||
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 strings.EqualFold(plistString(signaling.value["CountryOfOriginationFormat"]), "PANI") {
|
||||
enabled := true
|
||||
rule.IMS.PANIEnabled = &enabled
|
||||
rule.IMS.PANICountry = "AUTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
if useIPSec {
|
||||
|
||||
@@ -42,9 +42,12 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
||||
},
|
||||
"IMSConfig": map[string]any{
|
||||
"EnableWiFiCallingWithoutEntitlement": true,
|
||||
"Signaling": map[string]any{"UseIPSec": true},
|
||||
"Media": map[string]any{"SupportPCMA": false},
|
||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||
"Signaling": map[string]any{
|
||||
"UseIPSec": true,
|
||||
"CountryOfOriginationFormat": "PANI",
|
||||
},
|
||||
"Media": map[string]any{"SupportPCMA": false},
|
||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -73,6 +76,9 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
||||
if rule.IMS.IPSecEncryption != "aes-cbc" {
|
||||
t.Fatalf("converted IMS profile = %#v", rule.IMS)
|
||||
}
|
||||
if rule.IMS.PANIEnabled == nil || !*rule.IMS.PANIEnabled || rule.IMS.PANICountry != "AUTO" {
|
||||
t.Fatalf("converted PANI behavior = enabled=%v country=%q", rule.IMS.PANIEnabled, rule.IMS.PANICountry)
|
||||
}
|
||||
for _, code := range []string{
|
||||
"remote_certificate_bypass_ignored",
|
||||
"disabled_dpd_ignored",
|
||||
|
||||
+11781
-62
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,9 @@ var (
|
||||
const (
|
||||
usimAIDPrefix = "A0000000871002"
|
||||
isimAIDPrefix = "A0000000871004"
|
||||
efDIRFileID = 0x2f00
|
||||
efADDecimal = 28589 // 0x6FAD
|
||||
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
|
||||
channelCleanupTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
@@ -239,14 +241,79 @@ func (adapter *EC20Adapter) readHomePLMN(
|
||||
return mcc, mnc, nil
|
||||
}
|
||||
}
|
||||
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
|
||||
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
|
||||
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic.
|
||||
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
||||
return mcc, mnc, nil
|
||||
}
|
||||
// 3GPP TS 31.102 Section 4.2.84: Query EF_EHPLMN (Equivalent Home PLMN).
|
||||
if ehplmns, err := adapter.readEHPLMN(ctx, deviceID); err == nil && len(ehplmns) > 0 {
|
||||
first := ehplmns[0]
|
||||
if len(first) >= 5 {
|
||||
return first[:3], first[3:], nil
|
||||
}
|
||||
}
|
||||
return "", "", efErr
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) readEHPLMN(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
) ([]string, error) {
|
||||
commands := []string{
|
||||
fmt.Sprintf("AT+CRSM=176,%d,0,0,0", efEHPLMNDecimal),
|
||||
fmt.Sprintf("AT+CRSM=176,%d,0,0,12", efEHPLMNDecimal),
|
||||
}
|
||||
var lastErr error
|
||||
for _, command := range commands {
|
||||
response, err := adapter.execute(ctx, deviceID, command)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
data, err := parseCRSMData(response)
|
||||
if err != nil || len(data) < 3 {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
plmns := parsePLMNListFromBytes(data)
|
||||
if len(plmns) > 0 {
|
||||
return plmns, nil
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("vocat: EF_EHPLMN is empty or unavailable")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func parsePLMNListFromBytes(data []byte) []string {
|
||||
var plmns []string
|
||||
for i := 0; i+3 <= len(data); i += 3 {
|
||||
b1, b2, b3 := data[i], data[i+1], data[i+2]
|
||||
mcc1 := b1 & 0x0f
|
||||
mcc2 := (b1 >> 4) & 0x0f
|
||||
mcc3 := b2 & 0x0f
|
||||
mnc3 := (b2 >> 4) & 0x0f
|
||||
mnc1 := b3 & 0x0f
|
||||
mnc2 := (b3 >> 4) & 0x0f
|
||||
|
||||
if mcc1 > 9 || mcc2 > 9 || mcc3 > 9 || mnc1 > 9 || mnc2 > 9 {
|
||||
continue
|
||||
}
|
||||
mcc := fmt.Sprintf("%d%d%d", mcc1, mcc2, mcc3)
|
||||
var mnc string
|
||||
if mnc3 <= 9 {
|
||||
mnc = fmt.Sprintf("%d%d%d", mnc1, mnc2, mnc3)
|
||||
} else {
|
||||
mnc = fmt.Sprintf("%d%d", mnc1, mnc2)
|
||||
}
|
||||
if len(mcc) == 3 && (len(mnc) == 2 || len(mnc) == 3) {
|
||||
plmns = append(plmns, mcc+mnc)
|
||||
}
|
||||
}
|
||||
return plmns
|
||||
}
|
||||
|
||||
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
||||
assignments := []struct {
|
||||
prefix string
|
||||
@@ -959,6 +1026,19 @@ func (adapter *EC20Adapter) discoverAKAApplication(
|
||||
}
|
||||
}
|
||||
}
|
||||
// CUAD is optional and is rejected by a number of EC20 firmware branches.
|
||||
// In that case do not immediately fall back to the seven-byte registered
|
||||
// application-provider prefix: cards may expose multiple USIM instances and
|
||||
// require the complete PIX from EF_DIR to select the provisioned one. Read
|
||||
// EF_DIR over the standards-based basic channel, which remains available on
|
||||
// the same firmware that rejects CCHO/CGLA.
|
||||
if discovered, discoverErr := adapter.discoverBasicApplicationAID(
|
||||
ctx,
|
||||
deviceID,
|
||||
usimAIDPrefix,
|
||||
); discoverErr == nil {
|
||||
return discovered, "USIM", nil
|
||||
}
|
||||
|
||||
// AT+CUAD is optional on older EC20 firmware. CCHO still provides a
|
||||
// standards-based, evidence-bearing probe of the assigned USIM AID.
|
||||
@@ -987,6 +1067,64 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication(
|
||||
return aidPrefix, application, nil
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) discoverBasicApplicationAID(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
aidPrefix string,
|
||||
) (string, error) {
|
||||
selectFile := func(fileID uint16) error {
|
||||
apdu := []byte{
|
||||
0x00, 0xa4, 0x00, 0x04, 0x02,
|
||||
byte(fileID >> 8), byte(fileID), 0x00,
|
||||
}
|
||||
raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, status, err := splitAPDUStatus(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 0x9000 {
|
||||
return fmt.Errorf("vocat: EC20 basic-channel SELECT returned %04X", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := selectFile(0x3f00); err != nil {
|
||||
return "", fmt.Errorf("select EC20 MF for application discovery: %w", err)
|
||||
}
|
||||
if err := selectFile(efDIRFileID); err != nil {
|
||||
return "", fmt.Errorf("select EC20 EF_DIR for application discovery: %w", err)
|
||||
}
|
||||
for record := 1; record <= 32; record++ {
|
||||
raw, err := adapter.transmitBasicAPDU(
|
||||
ctx,
|
||||
deviceID,
|
||||
[]byte{0x00, 0xb2, byte(record), 0x04, 0x00},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read EC20 EF_DIR record %d: %w", record, err)
|
||||
}
|
||||
body, status, err := splitAPDUStatus(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status == 0x6a83 || status == 0x9402 {
|
||||
break
|
||||
}
|
||||
if status != 0x9000 {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range collectApplicationAIDs(body) {
|
||||
if strings.HasPrefix(candidate, aidPrefix) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ErrEC20ApplicationAbsent
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) openLogicalChannel(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
@@ -1108,8 +1246,17 @@ func (adapter *EC20Adapter) transmitBasicAPDU(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collected = append(collected, body...)
|
||||
sw1 := byte(status >> 8)
|
||||
if sw1 == 0x6c {
|
||||
// The UICC knows the exact response length. Retry the original APDU
|
||||
// with the advised Le without retaining the procedure response.
|
||||
if len(current) < 5 {
|
||||
return nil, errors.New("vocat: EC20 APDU cannot apply corrected response length")
|
||||
}
|
||||
current[len(current)-1] = byte(status)
|
||||
continue
|
||||
}
|
||||
collected = append(collected, body...)
|
||||
if sw1 != 0x61 && sw1 != 0x9f {
|
||||
collected = append(collected, byte(status>>8), byte(status))
|
||||
return collected, nil
|
||||
|
||||
@@ -263,6 +263,59 @@ func TestEC20AdapterCSIMFallbackSupportsSuccessAndSynchronizationFailure(
|
||||
}
|
||||
}
|
||||
|
||||
func TestEC20AdapterDiscoversFullUSIMAIDFromEFDIRWhenCUADFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
const fullAID = "A0000000871002FFFFFFFF8903020000"
|
||||
record := "61184F10" + fullAID + "50045553494D"
|
||||
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
|
||||
var challenge AKAChallenge
|
||||
for index := range challenge.RAND {
|
||||
challenge.RAND[index] = byte(index)
|
||||
challenge.AUTN[index] = byte(0xf0 + index)
|
||||
}
|
||||
authAPDU := buildUSIMAuthenticateAPDU(challenge)
|
||||
authCommand := fmt.Sprintf(
|
||||
`AT+CSIM=%d,"%s"`,
|
||||
len(authAPDU)*2,
|
||||
strings.ToUpper(hex.EncodeToString(authAPDU)),
|
||||
)
|
||||
selectApplication := `AT+CSIM=42,"00A4040410` + fullAID + `"`
|
||||
transcript := &ec20Transcript{
|
||||
t: t,
|
||||
steps: append(
|
||||
identityTranscriptStepsWithoutEFAD("310280000000001"),
|
||||
[]ec20TranscriptStep{
|
||||
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
|
||||
{command: "AT+CUAD", err: errors.New("+CME ERROR: 13"), final: "+CME ERROR: 13"},
|
||||
{command: `AT+CSIM=16,"00A40004023F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
|
||||
{command: `AT+CSIM=16,"00A40004022F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
|
||||
{command: `AT+CSIM=10,"00B2010400"`, lines: []string{`+CSIM: 4,"6C1A"`}},
|
||||
{command: `AT+CSIM=10,"00B201041A"`, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s9000"`, len(record)+4, record)}},
|
||||
{command: `AT+CCHO="` + fullAID + `"`, err: errors.New("unsupported"), final: "ERROR"},
|
||||
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
|
||||
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
|
||||
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
|
||||
{command: authCommand, sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encodedResponse), encodedResponse)}},
|
||||
}...,
|
||||
),
|
||||
}
|
||||
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadIdentity: %v", err)
|
||||
}
|
||||
if _, err := adapter.CheckReady(context.Background(), identity); err != nil {
|
||||
t.Fatalf("CheckReady: %v", err)
|
||||
}
|
||||
if _, err := adapter.Authenticate(context.Background(), identity, challenge); err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
transcript.assertDone()
|
||||
}
|
||||
|
||||
func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse(
|
||||
t *testing.T,
|
||||
) {
|
||||
|
||||
@@ -280,6 +280,239 @@ func decryptPayloads(
|
||||
return header, payloads, nil
|
||||
}
|
||||
|
||||
const defaultIKEFragmentSize = 1100
|
||||
|
||||
func encryptPayloadsFragmented(
|
||||
header ikeHeader,
|
||||
inner []payload,
|
||||
suite negotiatedSuite,
|
||||
encryptionKey []byte,
|
||||
integrityKey []byte,
|
||||
maxFragmentSize int,
|
||||
random io.Reader,
|
||||
) ([][]byte, error) {
|
||||
if random == nil {
|
||||
random = rand.Reader
|
||||
}
|
||||
if maxFragmentSize <= 0 {
|
||||
maxFragmentSize = defaultIKEFragmentSize
|
||||
}
|
||||
first, plaintext, err := marshalPayloadChain(inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ike: initialize AES: %w", err)
|
||||
}
|
||||
_, checksumLength, err := suite.integrityLengths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxChunk := maxFragmentSize - ikeHeaderLength - 8 - block.BlockSize() - block.BlockSize() - checksumLength
|
||||
if maxChunk < 64 {
|
||||
maxChunk = 64
|
||||
}
|
||||
|
||||
var chunks [][]byte
|
||||
for len(plaintext) > 0 {
|
||||
take := len(plaintext)
|
||||
if take > maxChunk {
|
||||
take = maxChunk
|
||||
}
|
||||
chunks = append(chunks, plaintext[:take])
|
||||
plaintext = plaintext[take:]
|
||||
}
|
||||
totalFragments := uint16(len(chunks))
|
||||
if totalFragments == 0 {
|
||||
totalFragments = 1
|
||||
chunks = [][]byte{nil}
|
||||
}
|
||||
|
||||
var packets [][]byte
|
||||
for index, chunk := range chunks {
|
||||
fragNum := uint16(index + 1)
|
||||
fragNext := uint8(payloadNone)
|
||||
if fragNum == 1 {
|
||||
fragNext = first
|
||||
}
|
||||
|
||||
paddingLength := block.BlockSize() - (len(chunk)+1)%block.BlockSize()
|
||||
if paddingLength == block.BlockSize() {
|
||||
paddingLength = 0
|
||||
}
|
||||
padding := make([]byte, paddingLength)
|
||||
if _, err := io.ReadFull(random, padding); err != nil {
|
||||
return nil, fmt.Errorf("ike: generate encrypted payload padding: %w", err)
|
||||
}
|
||||
paddedChunk := append(append([]byte(nil), chunk...), padding...)
|
||||
paddedChunk = append(paddedChunk, byte(paddingLength))
|
||||
|
||||
iv := make([]byte, block.BlockSize())
|
||||
if _, err := io.ReadFull(random, iv); err != nil {
|
||||
return nil, fmt.Errorf("ike: generate encrypted payload IV: %w", err)
|
||||
}
|
||||
ciphertext := make([]byte, len(paddedChunk))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, paddedChunk)
|
||||
|
||||
skfLength := 4 + 4 + len(iv) + len(ciphertext) + checksumLength
|
||||
if skfLength > 65535 {
|
||||
return nil, errors.New("ike: encrypted fragment exceeds 65535 bytes")
|
||||
}
|
||||
|
||||
body := make([]byte, skfLength)
|
||||
body[0] = fragNext
|
||||
body[1] = 0
|
||||
binary.BigEndian.PutUint16(body[2:4], uint16(skfLength))
|
||||
binary.BigEndian.PutUint16(body[4:6], fragNum)
|
||||
binary.BigEndian.PutUint16(body[6:8], totalFragments)
|
||||
copy(body[8:], iv)
|
||||
copy(body[8+len(iv):], ciphertext)
|
||||
|
||||
fragHeader := header
|
||||
fragHeader.NextPayload = payloadEncryptedFragment
|
||||
packet := fragHeader.marshal(body)
|
||||
checksum, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(packet[len(packet)-checksumLength:], checksum)
|
||||
packets = append(packets, packet)
|
||||
}
|
||||
return packets, nil
|
||||
}
|
||||
|
||||
func decryptSingleFragment(
|
||||
packet []byte,
|
||||
suite negotiatedSuite,
|
||||
encryptionKey []byte,
|
||||
integrityKey []byte,
|
||||
) (ikeHeader, uint8, uint16, uint16, []byte, error) {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return ikeHeader{}, 0, 0, 0, nil, err
|
||||
}
|
||||
if header.NextPayload != payloadEncryptedFragment || len(body) < 8 {
|
||||
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: message is not an encrypted IKE fragment", errUnexpectedPacket)
|
||||
}
|
||||
skfLength := int(binary.BigEndian.Uint16(body[2:4]))
|
||||
if skfLength != len(body) {
|
||||
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment length mismatch", errMalformedPacket)
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("ike: initialize AES: %w", err)
|
||||
}
|
||||
_, checksumLength, err := suite.integrityLengths()
|
||||
if err != nil {
|
||||
return ikeHeader{}, 0, 0, 0, nil, err
|
||||
}
|
||||
if len(body) < 8+block.BlockSize()+block.BlockSize()+checksumLength {
|
||||
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment is too short", errMalformedPacket)
|
||||
}
|
||||
expected, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
|
||||
if err != nil {
|
||||
return ikeHeader{}, 0, 0, 0, nil, err
|
||||
}
|
||||
actual := packet[len(packet)-checksumLength:]
|
||||
if subtle.ConstantTimeCompare(actual, expected) != 1 {
|
||||
return ikeHeader{}, 0, 0, 0, nil, errIntegrityMismatch
|
||||
}
|
||||
|
||||
fragNext := body[0]
|
||||
fragNum := binary.BigEndian.Uint16(body[4:6])
|
||||
totalFrags := binary.BigEndian.Uint16(body[6:8])
|
||||
if fragNum == 0 || totalFrags == 0 || fragNum > totalFrags {
|
||||
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid fragment numbers %d/%d", errMalformedPacket, fragNum, totalFrags)
|
||||
}
|
||||
|
||||
ivStart := 8
|
||||
ciphertextStart := ivStart + block.BlockSize()
|
||||
ciphertextEnd := len(body) - checksumLength
|
||||
ciphertext := body[ciphertextStart:ciphertextEnd]
|
||||
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
|
||||
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: fragment ciphertext is not block aligned", errMalformedPacket)
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
cipher.NewCBCDecrypter(block, body[ivStart:ciphertextStart]).CryptBlocks(plaintext, ciphertext)
|
||||
paddingLength := int(plaintext[len(plaintext)-1])
|
||||
if paddingLength+1 > len(plaintext) {
|
||||
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid encrypted fragment padding", errMalformedPacket)
|
||||
}
|
||||
plaintext = plaintext[:len(plaintext)-paddingLength-1]
|
||||
return header, fragNext, fragNum, totalFrags, plaintext, nil
|
||||
}
|
||||
|
||||
func decryptPayloadsAny(
|
||||
packet []byte,
|
||||
fragments [][]byte,
|
||||
suite negotiatedSuite,
|
||||
encryptionKey []byte,
|
||||
integrityKey []byte,
|
||||
) (ikeHeader, []payload, error) {
|
||||
if len(fragments) > 0 {
|
||||
var (
|
||||
firstHeader ikeHeader
|
||||
firstNext uint8
|
||||
totalExpected uint16
|
||||
plaintexts = make(map[uint16][]byte)
|
||||
)
|
||||
for _, fragPacket := range fragments {
|
||||
hdr, next, num, total, plain, err := decryptSingleFragment(fragPacket, suite, encryptionKey, integrityKey)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
if totalExpected == 0 {
|
||||
firstHeader = hdr
|
||||
totalExpected = total
|
||||
} else if total != totalExpected || hdr.MessageID != firstHeader.MessageID || hdr.Exchange != firstHeader.Exchange {
|
||||
return ikeHeader{}, nil, fmt.Errorf("%w: inconsistent fragment headers", errMalformedPacket)
|
||||
}
|
||||
if num == 1 {
|
||||
firstNext = next
|
||||
}
|
||||
plaintexts[num] = plain
|
||||
}
|
||||
if uint16(len(plaintexts)) != totalExpected {
|
||||
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragments: received %d of %d", errMalformedPacket, len(plaintexts), totalExpected)
|
||||
}
|
||||
var fullPlaintext []byte
|
||||
for i := uint16(1); i <= totalExpected; i++ {
|
||||
chunk, ok := plaintexts[i]
|
||||
if !ok {
|
||||
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragment %d", errMalformedPacket, i)
|
||||
}
|
||||
fullPlaintext = append(fullPlaintext, chunk...)
|
||||
}
|
||||
payloads, err := parsePayloadChain(firstNext, fullPlaintext)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
return firstHeader, payloads, nil
|
||||
}
|
||||
|
||||
header, _, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
if header.NextPayload == payloadEncryptedFragment {
|
||||
hdr, next, num, total, plain, err := decryptSingleFragment(packet, suite, encryptionKey, integrityKey)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
if num != 1 || total != 1 {
|
||||
return ikeHeader{}, nil, fmt.Errorf("%w: standalone fragment with total=%d", errMalformedPacket, total)
|
||||
}
|
||||
payloads, err := parsePayloadChain(next, plain)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
return hdr, payloads, nil
|
||||
}
|
||||
return decryptPayloads(packet, suite, encryptionKey, integrityKey)
|
||||
}
|
||||
|
||||
var modpPrimes = map[uint16]string{
|
||||
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
||||
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
||||
|
||||
@@ -113,3 +113,80 @@ func TestIKEKeyDerivationSeparatesDirections(t *testing.T) {
|
||||
t.Fatal("initiator and responder keys were not separated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRFC7383FragmentationAndReassembly(t *testing.T) {
|
||||
suite := legacyTestSuite()
|
||||
encryptionKey := bytes.Repeat([]byte{0x11}, 16)
|
||||
integrityKey := bytes.Repeat([]byte{0x22}, 20)
|
||||
header := ikeHeader{
|
||||
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
ResponderSPI: [8]byte{8, 7, 6, 5, 4, 3, 2, 1},
|
||||
Exchange: exchangeIKEAuth,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 9,
|
||||
}
|
||||
|
||||
largeCertData := bytes.Repeat([]byte{0xAB, 0xCD, 0xEF, 0x01}, 400) // 1600 bytes
|
||||
inner := []payload{
|
||||
{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u', 's', 'e', 'r'}},
|
||||
{Type: payloadCert, Body: largeCertData},
|
||||
{Type: payloadAuth, Body: bytes.Repeat([]byte{0x55}, 64)},
|
||||
}
|
||||
|
||||
// Fragment into chunks with max fragment size 600 bytes
|
||||
packets, err := encryptPayloadsFragmented(
|
||||
header,
|
||||
inner,
|
||||
suite,
|
||||
encryptionKey,
|
||||
integrityKey,
|
||||
600,
|
||||
bytes.NewReader(bytes.Repeat([]byte{0x77}, 1024)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPayloadsFragmented() error = %v", err)
|
||||
}
|
||||
|
||||
if len(packets) < 3 {
|
||||
t.Fatalf("expected at least 3 fragments for large payload, got %d", len(packets))
|
||||
}
|
||||
|
||||
for i, pkt := range packets {
|
||||
hdr, body, parseErr := parseIKEPacket(pkt)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("fragment %d parse error: %v", i+1, parseErr)
|
||||
}
|
||||
if hdr.NextPayload != payloadEncryptedFragment {
|
||||
t.Fatalf("fragment %d NextPayload = %d, want %d (payloadEncryptedFragment)", i+1, hdr.NextPayload, payloadEncryptedFragment)
|
||||
}
|
||||
if len(body) < 8 {
|
||||
t.Fatalf("fragment %d body too short", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt and reassemble
|
||||
decodedHeader, decoded, err := decryptPayloadsAny(nil, packets, suite, encryptionKey, integrityKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decryptPayloadsAny() error = %v", err)
|
||||
}
|
||||
|
||||
if decodedHeader.MessageID != header.MessageID || len(decoded) != len(inner) {
|
||||
t.Fatalf("reassembled payload mismatch: header=%#v, count=%d, want=%d", decodedHeader, len(decoded), len(inner))
|
||||
}
|
||||
|
||||
for index := range inner {
|
||||
if decoded[index].Type != inner[index].Type || !bytes.Equal(decoded[index].Body, inner[index].Body) {
|
||||
t.Fatalf("decoded payload %d = %#v, want %#v", index, decoded[index], inner[index])
|
||||
}
|
||||
}
|
||||
|
||||
// Test tamper detection on second fragment
|
||||
tamperedPackets := make([][]byte, len(packets))
|
||||
for i := range packets {
|
||||
tamperedPackets[i] = append([]byte(nil), packets[i]...)
|
||||
}
|
||||
tamperedPackets[1][len(tamperedPackets[1])-1] ^= 0x55
|
||||
if _, _, err := decryptPayloadsAny(nil, tamperedPackets, suite, encryptionKey, integrityKey); !errors.Is(err, errIntegrityMismatch) {
|
||||
t.Fatalf("tampered fragment decrypt error = %v, want errIntegrityMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,8 +264,13 @@ func permanentAKAIdentity(identity vowifi.SIMIdentity) ([]byte, error) {
|
||||
return nil, errors.New("ike: IMSI contains a non-digit")
|
||||
}
|
||||
}
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
profile := vowifi.ResolveCarrierProfile(identity)
|
||||
mcc := strings.TrimSpace(profile.RouteMCC)
|
||||
mnc := strings.TrimSpace(profile.RouteMNC)
|
||||
if mcc == "" || mnc == "" {
|
||||
mcc = strings.TrimSpace(identity.HomeMCC)
|
||||
mnc = strings.TrimSpace(identity.HomeMNC)
|
||||
}
|
||||
if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) {
|
||||
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
|
||||
}
|
||||
|
||||
@@ -28,48 +28,78 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
||||
if resolver == nil {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
|
||||
if systemErr == nil && len(addresses) > 0 {
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||
if subnet == "" {
|
||||
if systemErr != nil {
|
||||
return nil, systemErr
|
||||
}
|
||||
return nil, errors.New("ePDG did not resolve to an IP address")
|
||||
hostsToTry := []string{normalized}
|
||||
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
||||
hostsToTry = append(hostsToTry, alt)
|
||||
}
|
||||
|
||||
var systemErr error
|
||||
for _, targetHost := range hostsToTry {
|
||||
ips, err := resolver.LookupIP(ctx, "ip4", targetHost)
|
||||
if err == nil {
|
||||
addresses := make([]net.IPAddr, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
addresses = append(addresses, net.IPAddr{IP: ip})
|
||||
}
|
||||
valid := filterValidPublicEPDGAddresses(addresses)
|
||||
if len(valid) > 0 {
|
||||
return valid, nil
|
||||
}
|
||||
} else {
|
||||
systemErr = err
|
||||
}
|
||||
}
|
||||
|
||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
var fallbackErr error
|
||||
// Vodafone's authoritative response has a 60-second TTL and recursive
|
||||
// resolvers can briefly cache the global CNAME without its geo-restricted
|
||||
// address records. Stay inside the runtime's two-minute setup window and
|
||||
// wait through one complete negative-cache TTL so a single reconnect is
|
||||
// sufficient; users should not have to click Reconnect repeatedly.
|
||||
const fallbackAttempts = 13
|
||||
for attempt := 0; attempt < fallbackAttempts; attempt++ {
|
||||
|
||||
for _, targetHost := range hostsToTry {
|
||||
var fallback []net.IPAddr
|
||||
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, normalized, subnet)
|
||||
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
|
||||
if fallbackErr == nil && len(fallback) > 0 {
|
||||
return fallback, nil
|
||||
}
|
||||
if attempt+1 < fallbackAttempts {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
if systemErr == nil {
|
||||
systemErr = errors.New("system DNS returned no IP addresses")
|
||||
systemErr = errors.New("system DNS returned no usable public IP addresses")
|
||||
}
|
||||
return nil, fmt.Errorf("system DNS failed (%v); geographic DNS fallback failed: %w", systemErr, fallbackErr)
|
||||
}
|
||||
|
||||
func filterValidPublicEPDGAddresses(addresses []net.IPAddr) []net.IPAddr {
|
||||
result := make([]net.IPAddr, 0, len(addresses))
|
||||
for _, addr := range addresses {
|
||||
if addr.IP == nil || addr.IP.IsLoopback() || addr.IP.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
result = append(result, addr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func alternate3GPPHostname(host string) string {
|
||||
const prefix = "epdg.epc.mnc"
|
||||
if !strings.HasPrefix(host, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := host[len(prefix):]
|
||||
dot := strings.Index(rest, ".")
|
||||
if dot <= 0 {
|
||||
return ""
|
||||
}
|
||||
mnc := rest[:dot]
|
||||
suffix := rest[dot:]
|
||||
if len(mnc) == 3 && strings.HasPrefix(mnc, "0") {
|
||||
return prefix + mnc[1:] + suffix
|
||||
}
|
||||
if len(mnc) == 2 {
|
||||
return prefix + "0" + mnc + suffix
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveEPDGWithECS(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
@@ -85,7 +115,9 @@ func resolveEPDGWithECS(
|
||||
query := parsed.Query()
|
||||
query.Set("name", strings.TrimSpace(host))
|
||||
query.Set("type", "A")
|
||||
query.Set("edns_client_subnet", strings.TrimSpace(subnet))
|
||||
if strings.TrimSpace(subnet) != "" {
|
||||
query.Set("edns_client_subnet", strings.TrimSpace(subnet))
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
@@ -116,7 +148,7 @@ func resolveEPDGWithECS(
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), "."))
|
||||
if ip == nil {
|
||||
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
duplicate := false
|
||||
|
||||
@@ -393,9 +393,11 @@ func parseInnerIPv6(packet []byte) (innerPacketMetadata, error) {
|
||||
return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated")
|
||||
}
|
||||
payloadLength := int(binary.BigEndian.Uint16(packet[4:6]))
|
||||
if payloadLength+40 != len(packet) {
|
||||
declaredLength := payloadLength + 40
|
||||
if declaredLength > len(packet) {
|
||||
return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid")
|
||||
}
|
||||
packet = packet[:declaredLength]
|
||||
metadata := innerPacketMetadata{
|
||||
source: append(net.IP(nil), packet[8:24]...),
|
||||
destination: append(net.IP(nil), packet[24:40]...),
|
||||
|
||||
@@ -318,6 +318,26 @@ func TestParseInnerIPv6ESP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInnerIPv6ESPTrimsTrailingAlignmentBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
packet := make([]byte, 40+20+4)
|
||||
packet[0] = 0x60
|
||||
binary.BigEndian.PutUint16(packet[4:6], 20)
|
||||
packet[6] = 6
|
||||
packet[7] = 64
|
||||
copy(packet[8:24], net.ParseIP("2001:db8::1").To16())
|
||||
copy(packet[24:40], net.ParseIP("2001:db8::2").To16())
|
||||
binary.BigEndian.PutUint16(packet[40:42], 49686)
|
||||
binary.BigEndian.PutUint16(packet[42:44], 5060)
|
||||
metadata, err := parseInnerPacket(packet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.protocol != 6 || metadata.sourcePort != 49686 || metadata.destinationPort != 5060 {
|
||||
t.Fatalf("metadata = %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func mustDefaultESPTunnel(t *testing.T) *espTunnel {
|
||||
t.Helper()
|
||||
return mustTestESPTunnel(
|
||||
|
||||
+180
-60
@@ -187,36 +187,64 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
{Type: payloadNonce, Body: initiatorNonce},
|
||||
makeNotify(notifyNATSource, sourceHash),
|
||||
makeNotify(notifyNATDestination, destinationHash),
|
||||
makeNotify(notifyFragmentationSupported, nil),
|
||||
}
|
||||
first, initBody, err := marshalPayloadChain(initPayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initRequest := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}.marshal(initBody)
|
||||
initResponse, err := transport.RoundTrip(ctx, initRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseHeader, responseBody, err := validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if responseHeader.ResponderSPI == [8]byte{} {
|
||||
return nil, errors.New("ike: responder returned a zero SPI")
|
||||
}
|
||||
initResponsePayloads, err := parsePayloadChain(responseHeader.NextPayload, responseBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
|
||||
return nil, err
|
||||
var (
|
||||
initRequest []byte
|
||||
initResponse []byte
|
||||
responseHeader ikeHeader
|
||||
initResponsePayloads []payload
|
||||
cookie []byte
|
||||
)
|
||||
for attempt := 0; attempt < maxIKEInitCookieChallenges; attempt++ {
|
||||
requestPayloads := append([]payload(nil), initPayloads...)
|
||||
if len(cookie) > 0 {
|
||||
requestPayloads = append([]payload{makeNotify(notifyCookie, cookie)}, requestPayloads...)
|
||||
}
|
||||
first, initBody, err := marshalPayloadChain(requestPayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initRequest = ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}.marshal(initBody)
|
||||
initResponse, err = transport.RoundTrip(ctx, initRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var responseBody []byte
|
||||
responseHeader, responseBody, err = validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initResponsePayloads, err = parsePayloadChain(responseHeader.NextPayload, responseBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challenge, hasCookie, err := ikeInitCookie(initResponsePayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCookie {
|
||||
if attempt+1 == maxIKEInitCookieChallenges {
|
||||
return nil, errors.New("ike: ePDG requested too many COOKIE challenges")
|
||||
}
|
||||
cookie = challenge
|
||||
continue
|
||||
}
|
||||
if responseHeader.ResponderSPI == [8]byte{} {
|
||||
return nil, errors.New("ike: responder returned a zero SPI")
|
||||
}
|
||||
break
|
||||
}
|
||||
peerSupportsFragmentation := hasNotifyType(initResponsePayloads, notifyFragmentationSupported)
|
||||
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -319,21 +347,19 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
Flags: flagInitiator,
|
||||
MessageID: 1,
|
||||
}
|
||||
authRequest, err := encryptPayloads(authHeader, firstAuthPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authResponse, err := transport.RoundTrip(ctx, authRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authResponseHeader, authResponsePayloads, err := decryptAndValidate(
|
||||
authResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, 1, ikeSuite, keys,
|
||||
_, authResponsePayloads, err := sendAndReceiveIKEPayloads(
|
||||
ctx,
|
||||
transport,
|
||||
authHeader,
|
||||
firstAuthPayloads,
|
||||
ikeSuite,
|
||||
keys,
|
||||
peerSupportsFragmentation,
|
||||
provider.config.Random,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = authResponseHeader
|
||||
serverName := strings.TrimSpace(provider.config.ServerName)
|
||||
if serverName == "" {
|
||||
serverName = epdg
|
||||
@@ -383,27 +409,27 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
requestPayloads = append(requestPayloads, deviceIdentity)
|
||||
}
|
||||
}
|
||||
eapRequest, err := encryptPayloads(ikeHeader{
|
||||
eapHeader := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
Exchange: exchangeIKEAuth,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
||||
return nil, notifyErr
|
||||
} else if requested {
|
||||
deviceIdentityPending = true
|
||||
}
|
||||
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, currentPayloads, err = decryptAndValidate(
|
||||
eapResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
||||
_, currentPayloads, err = sendAndReceiveIKEPayloads(
|
||||
ctx,
|
||||
transport,
|
||||
eapHeader,
|
||||
requestPayloads,
|
||||
ikeSuite,
|
||||
keys,
|
||||
peerSupportsFragmentation,
|
||||
provider.config.Random,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -428,22 +454,22 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
}
|
||||
messageID++
|
||||
cleanupMessageID = messageID + 1
|
||||
finalRequest, err := encryptPayloads(ikeHeader{
|
||||
finalHeader := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
Exchange: exchangeIKEAuth,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, []payload{initiatorAUTH}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalResponse, err := transport.RoundTrip(ctx, finalRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, finalPayloads, err := decryptAndValidate(
|
||||
finalResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
||||
_, finalPayloads, err := sendAndReceiveIKEPayloads(
|
||||
ctx,
|
||||
transport,
|
||||
finalHeader,
|
||||
[]payload{initiatorAUTH},
|
||||
ikeSuite,
|
||||
keys,
|
||||
peerSupportsFragmentation,
|
||||
provider.config.Random,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -616,6 +642,29 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
return session, nil
|
||||
}
|
||||
|
||||
const maxIKEInitCookieChallenges = 2
|
||||
|
||||
func ikeInitCookie(payloads []payload) ([]byte, bool, error) {
|
||||
var cookie []byte
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if kind != notifyCookie {
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, false, errors.New("ike: ePDG returned an empty COOKIE")
|
||||
}
|
||||
if cookie != nil {
|
||||
return nil, false, errors.New("ike: ePDG returned multiple COOKIE notifications")
|
||||
}
|
||||
cookie = append([]byte(nil), data...)
|
||||
}
|
||||
return cookie, cookie != nil, nil
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
@@ -714,7 +763,7 @@ func decryptAndValidate(
|
||||
suite negotiatedSuite,
|
||||
keys ikeKeys,
|
||||
) (ikeHeader, []payload, error) {
|
||||
header, payloads, err := decryptPayloads(packet, suite, keys.SKer, keys.SKar)
|
||||
header, payloads, err := decryptPayloadsAny(packet, nil, suite, keys.SKer, keys.SKar)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
@@ -729,6 +778,77 @@ func decryptAndValidate(
|
||||
return header, payloads, nil
|
||||
}
|
||||
|
||||
func decryptAndValidateFragments(
|
||||
packets [][]byte,
|
||||
initiatorSPI [8]byte,
|
||||
responderSPI [8]byte,
|
||||
exchange uint8,
|
||||
messageID uint32,
|
||||
suite negotiatedSuite,
|
||||
keys ikeKeys,
|
||||
) (ikeHeader, []payload, error) {
|
||||
if len(packets) == 0 {
|
||||
return ikeHeader{}, nil, errors.New("ike: empty exchange response")
|
||||
}
|
||||
if len(packets) == 1 {
|
||||
return decryptAndValidate(packets[0], initiatorSPI, responderSPI, exchange, messageID, suite, keys)
|
||||
}
|
||||
header, payloads, err := decryptPayloadsAny(nil, packets, suite, keys.SKer, keys.SKar)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
if header.InitiatorSPI != initiatorSPI ||
|
||||
header.ResponderSPI != responderSPI ||
|
||||
header.Exchange != exchange ||
|
||||
header.MessageID != messageID ||
|
||||
header.Flags&flagResponse == 0 ||
|
||||
header.Flags&flagInitiator != 0 {
|
||||
return ikeHeader{}, nil, fmt.Errorf("%w: encrypted response header does not match the request", errUnexpectedPacket)
|
||||
}
|
||||
return header, payloads, nil
|
||||
}
|
||||
|
||||
func sendAndReceiveIKEPayloads(
|
||||
ctx context.Context,
|
||||
transport datagramTransport,
|
||||
header ikeHeader,
|
||||
payloads []payload,
|
||||
suite negotiatedSuite,
|
||||
keys ikeKeys,
|
||||
peerSupportsFragmentation bool,
|
||||
random io.Reader,
|
||||
) (ikeHeader, []payload, error) {
|
||||
var outboundPackets [][]byte
|
||||
var err error
|
||||
if peerSupportsFragmentation {
|
||||
outboundPackets, err = encryptPayloadsFragmented(header, payloads, suite, keys.SKei, keys.SKai, defaultIKEFragmentSize, random)
|
||||
} else {
|
||||
pkt, encryptErr := encryptPayloads(header, payloads, suite, keys.SKei, keys.SKai, random)
|
||||
if encryptErr != nil {
|
||||
return ikeHeader{}, nil, encryptErr
|
||||
}
|
||||
outboundPackets = [][]byte{pkt}
|
||||
}
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
inboundPackets, err := transport.RoundTripExchange(ctx, outboundPackets)
|
||||
if err != nil {
|
||||
return ikeHeader{}, nil, err
|
||||
}
|
||||
return decryptAndValidateFragments(inboundPackets, header.InitiatorSPI, header.ResponderSPI, header.Exchange, header.MessageID, suite, keys)
|
||||
}
|
||||
|
||||
func hasNotifyType(payloads []payload, notifyType uint16) bool {
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, _, err := parseNotify(item)
|
||||
if err == nil && kind == notifyType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||
|
||||
type invalidKEPayloadError struct {
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,25 +18,6 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
|
||||
|
||||
type constantReader struct{ value byte }
|
||||
|
||||
func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
mcc string
|
||||
mnc string
|
||||
}{
|
||||
{mcc: "234", mnc: "15"},
|
||||
{mcc: "204", mnc: "04"},
|
||||
{mcc: "204", mnc: "004"},
|
||||
} {
|
||||
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
||||
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
||||
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
||||
}
|
||||
}
|
||||
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errNoProposalChosen,
|
||||
@@ -64,17 +47,20 @@ func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
}
|
||||
|
||||
type firstAuthCaptureTransport struct {
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
cookieChallenge []byte
|
||||
cookieSeen bool
|
||||
cookieLoop bool
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
|
||||
@@ -90,8 +76,26 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
|
||||
func (transport *firstAuthCaptureTransport) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||
transport.calls++
|
||||
if len(transport.cookieChallenge) > 0 {
|
||||
switch transport.calls {
|
||||
case 1:
|
||||
return transport.answerIKECookie(packet)
|
||||
case 2:
|
||||
if err := transport.verifyIKECookie(packet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if transport.cookieLoop {
|
||||
return transport.answerIKECookie(packet)
|
||||
}
|
||||
return transport.answerIKEInit(packet)
|
||||
case 3:
|
||||
return nil, transport.observeFirstAuth(packet)
|
||||
default:
|
||||
return nil, errors.New("test: unexpected exchange")
|
||||
}
|
||||
}
|
||||
switch transport.calls {
|
||||
case 1:
|
||||
return transport.answerIKEInit(packet)
|
||||
@@ -102,6 +106,80 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||
if len(packets) == 0 {
|
||||
return nil, errors.New("test: empty outbound packets")
|
||||
}
|
||||
resp, err := transport.RoundTrip(ctx, packets[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return [][]byte{resp}, nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
||||
header, _, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first, body, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, transport.cookieChallenge),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ikeHeader{
|
||||
InitiatorSPI: header.InitiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
MessageID: 0,
|
||||
}.marshal(body), nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) verifyIKECookie(packet []byte) error {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if header.Exchange != exchangeIKEInit || header.MessageID != 0 || header.Flags != flagInitiator {
|
||||
return errors.New("test: invalid retried IKE_SA_INIT header")
|
||||
}
|
||||
payloads, err := parsePayloadChain(header.NextPayload, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payloads) == 0 || payloads[0].Type != payloadNotify {
|
||||
return errors.New("test: retried IKE_SA_INIT did not put COOKIE first")
|
||||
}
|
||||
firstKind, firstData, err := parseNotify(payloads[0])
|
||||
if err != nil || firstKind != notifyCookie || !bytes.Equal(firstData, transport.cookieChallenge) {
|
||||
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
||||
}
|
||||
cookies := payloadsOfType(payloads, payloadNotify)
|
||||
if len(cookies) != 4 {
|
||||
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 4", len(cookies))
|
||||
}
|
||||
found := false
|
||||
for _, item := range cookies {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == notifyCookie {
|
||||
if !bytes.Equal(data, transport.cookieChallenge) {
|
||||
return fmt.Errorf("test: cookie = %x, want %x", data, transport.cookieChallenge)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("test: retried IKE_SA_INIT did not carry COOKIE")
|
||||
}
|
||||
transport.cookieSeen = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte, error) {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
@@ -122,7 +200,7 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
||||
wantGroup := transport.wantGroup
|
||||
if wantGroup == 0 {
|
||||
wantGroup = dhMODP1024
|
||||
wantGroup = dhMODP2048
|
||||
}
|
||||
wantKELength := 128
|
||||
if wantGroup == dhMODP2048 {
|
||||
@@ -291,8 +369,12 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
||||
func TestProviderRetriesIKEInitAfterCookie(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{
|
||||
t: t,
|
||||
wantEAPOnly: true,
|
||||
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
|
||||
}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
@@ -312,21 +394,65 @@ func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-o2",
|
||||
DeviceID: "ec20-cookie",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8949200000000000000",
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
ICCID: "8944100000000000000",
|
||||
IMSI: "234150123456789",
|
||||
HomeMCC: "234",
|
||||
HomeMNC: "15",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
|
||||
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if !errors.Is(err, errFirstAuthObserved) {
|
||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
||||
}
|
||||
if capture.calls != 2 || capture.floated || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
|
||||
if capture.calls != 3 || !capture.cookieSeen || capture.floated || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d cookie_seen=%v floated=%v AKA calls=%d", capture.calls, capture.cookieSeen, capture.floated, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderBoundsRepeatedIKEInitCookieChallenges(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{
|
||||
t: t,
|
||||
wantEAPOnly: true,
|
||||
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
|
||||
cookieLoop: true,
|
||||
}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-cookie-loop",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8944100000000000000",
|
||||
IMSI: "234150123456789",
|
||||
HomeMCC: "234",
|
||||
HomeMNC: "15",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "too many COOKIE challenges") {
|
||||
t.Fatalf("Start() error = %v, want bounded COOKIE error", err)
|
||||
}
|
||||
if capture.calls != maxIKEInitCookieChallenges || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d AKA calls=%d", capture.calls, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func newSessionRelay(
|
||||
keepalive time.Duration,
|
||||
) *sessionRelay {
|
||||
if keepalive <= 0 {
|
||||
keepalive = 20 * time.Second
|
||||
keepalive = 15 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
relay := &sessionRelay{
|
||||
|
||||
@@ -3,6 +3,7 @@ package ike
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -49,6 +50,16 @@ func (*fakeSessionTransport) Float(context.Context) error { return nil }
|
||||
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
func (t *fakeSessionTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||
if len(packets) == 0 {
|
||||
return nil, errors.New("empty outbound packets")
|
||||
}
|
||||
resp, err := t.RoundTrip(ctx, packets[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return [][]byte{resp}, nil
|
||||
}
|
||||
func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error {
|
||||
return transport.SendSessionPacket(ctx, packet, false)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type datagramTransport interface {
|
||||
RemoteAddr() *net.UDPAddr
|
||||
Float(context.Context) error
|
||||
RoundTrip(context.Context, []byte) ([]byte, error)
|
||||
RoundTripExchange(context.Context, [][]byte) ([][]byte, error)
|
||||
SendESP(context.Context, []byte) error
|
||||
ReceiveESP(context.Context, []byte) (int, error)
|
||||
SendSessionPacket(context.Context, []byte, bool) error
|
||||
@@ -96,6 +97,29 @@ func roundTripDatagram(
|
||||
read func([]byte, time.Time) (int, error),
|
||||
packet []byte,
|
||||
) ([]byte, error) {
|
||||
writeAll := func(values [][]byte) error {
|
||||
if len(values) > 0 {
|
||||
return write(values[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
responses, err := roundTripFragments(ctx, timeout, writeAll, read, [][]byte{packet})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(responses) == 0 {
|
||||
return nil, errors.New("ike: empty datagram response")
|
||||
}
|
||||
return responses[0], nil
|
||||
}
|
||||
|
||||
func roundTripFragments(
|
||||
ctx context.Context,
|
||||
timeout time.Duration,
|
||||
writeAll func([][]byte) error,
|
||||
read func([]byte, time.Time) (int, error),
|
||||
packets [][]byte,
|
||||
) ([][]byte, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -110,20 +134,44 @@ func roundTripDatagram(
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := write(packet); err != nil {
|
||||
if err := writeAll(packets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attemptDeadline := time.Now().Add(interval)
|
||||
if deadline.Before(attemptDeadline) {
|
||||
attemptDeadline = deadline
|
||||
}
|
||||
var (
|
||||
totalExpected uint16
|
||||
fragments = make(map[uint16][]byte)
|
||||
)
|
||||
for time.Now().Before(attemptDeadline) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := read(buffer, attemptDeadline)
|
||||
if err == nil {
|
||||
return append([]byte(nil), buffer[:n]...), nil
|
||||
pkt := append([]byte(nil), buffer[:n]...)
|
||||
header, body, parseErr := parseIKEPacket(pkt)
|
||||
if parseErr == nil && header.NextPayload == payloadEncryptedFragment && len(body) >= 8 {
|
||||
fragNum := binary.BigEndian.Uint16(body[4:6])
|
||||
total := binary.BigEndian.Uint16(body[6:8])
|
||||
if total > 1 {
|
||||
if totalExpected == 0 {
|
||||
totalExpected = total
|
||||
}
|
||||
fragments[fragNum] = pkt
|
||||
if uint16(len(fragments)) == totalExpected {
|
||||
res := make([][]byte, 0, totalExpected)
|
||||
for i := uint16(1); i <= totalExpected; i++ {
|
||||
res = append(res, fragments[i])
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
return [][]byte{pkt}, nil
|
||||
}
|
||||
if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() {
|
||||
lastErr = err
|
||||
@@ -222,25 +270,47 @@ func (transport *directUDP) Float(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(responses) == 0 {
|
||||
return nil, errors.New("ike: empty exchange response")
|
||||
}
|
||||
return responses[0], nil
|
||||
}
|
||||
|
||||
func (transport *directUDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
if transport.conn == nil {
|
||||
return nil, errors.New("ike: UDP transport is closed")
|
||||
}
|
||||
requestHeader, _, err := parseIKEPacket(packet)
|
||||
if len(packets) == 0 {
|
||||
return nil, errors.New("ike: outbound packet list is empty")
|
||||
}
|
||||
requestHeader, _, err := parseIKEPacket(packets[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
||||
}
|
||||
wirePacket := packet
|
||||
if transport.floated {
|
||||
wirePacket = append([]byte{0, 0, 0, 0}, packet...)
|
||||
}
|
||||
write := func(value []byte) error {
|
||||
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||
return err
|
||||
var wirePackets [][]byte
|
||||
for _, pkt := range packets {
|
||||
wire := pkt
|
||||
if transport.floated {
|
||||
wire = append([]byte{0, 0, 0, 0}, pkt...)
|
||||
}
|
||||
_, err := transport.conn.Write(value)
|
||||
return err
|
||||
wirePackets = append(wirePackets, wire)
|
||||
}
|
||||
writeAll := func(values [][]byte) error {
|
||||
for _, value := range values {
|
||||
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := transport.conn.Write(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||
for {
|
||||
@@ -252,10 +322,6 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
||||
return 0, err
|
||||
}
|
||||
if transport.floated {
|
||||
// IKE and ESP legitimately share UDP/4500. An ESP packet can
|
||||
// arrive immediately before the IKE response that completes
|
||||
// CHILD_SA setup; discard it here and keep the same absolute
|
||||
// attempt deadline while waiting for marked IKE.
|
||||
if !hasNonESPMarker(buffer[:n]) {
|
||||
continue
|
||||
}
|
||||
@@ -268,7 +334,7 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return roundTripDatagram(ctx, transport.config.Timeout, write, read, wirePacket)
|
||||
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, wirePackets)
|
||||
}
|
||||
|
||||
func (transport *directUDP) SendESP(ctx context.Context, packet []byte) error {
|
||||
@@ -561,12 +627,26 @@ func (transport *socks5UDP) Float(_ context.Context) error {
|
||||
}
|
||||
|
||||
func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(responses) == 0 {
|
||||
return nil, errors.New("ike: empty exchange response")
|
||||
}
|
||||
return responses[0], nil
|
||||
}
|
||||
|
||||
func (transport *socks5UDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
if transport.udp == nil {
|
||||
return nil, errors.New("ike: SOCKS5 UDP transport is closed")
|
||||
}
|
||||
requestHeader, _, err := parseIKEPacket(packet)
|
||||
if len(packets) == 0 {
|
||||
return nil, errors.New("ike: outbound packet list is empty")
|
||||
}
|
||||
requestHeader, _, err := parseIKEPacket(packets[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
||||
}
|
||||
@@ -576,37 +656,55 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
||||
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
||||
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
||||
var lastErr error
|
||||
var cookieResponse [][]byte
|
||||
for _, candidate := range transport.remotes {
|
||||
transport.remote = cloneUDPAddr(candidate)
|
||||
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
||||
responses, attemptErr := transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
|
||||
if attemptErr == nil {
|
||||
return response, nil
|
||||
if len(responses) > 0 && ikeInitResponseHasCookie(responses[0]) {
|
||||
if cookieResponse == nil {
|
||||
cookieResponse = responses
|
||||
}
|
||||
continue
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
lastErr = attemptErr
|
||||
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
|
||||
return nil, attemptErr
|
||||
}
|
||||
}
|
||||
if cookieResponse != nil {
|
||||
return cookieResponse, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
||||
}
|
||||
return transport.roundTripLocked(ctx, packet, requestHeader)
|
||||
return transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
|
||||
}
|
||||
|
||||
func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte, requestHeader ikeHeader) ([]byte, error) {
|
||||
wireIKE := packet
|
||||
if transport.floated {
|
||||
wireIKE = append([]byte{0, 0, 0, 0}, packet...)
|
||||
}
|
||||
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
write := func(value []byte) error {
|
||||
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||
return err
|
||||
func (transport *socks5UDP) roundTripFragmentsLocked(ctx context.Context, packets [][]byte, requestHeader ikeHeader) ([][]byte, error) {
|
||||
var datagrams [][]byte
|
||||
for _, pkt := range packets {
|
||||
wireIKE := pkt
|
||||
if transport.floated {
|
||||
wireIKE = append([]byte{0, 0, 0, 0}, pkt...)
|
||||
}
|
||||
_, err := transport.udp.Write(value)
|
||||
return err
|
||||
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
datagrams = append(datagrams, datagram)
|
||||
}
|
||||
writeAll := func(values [][]byte) error {
|
||||
for _, value := range values {
|
||||
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := transport.udp.Write(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||
for {
|
||||
@@ -620,10 +718,6 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
||||
return 0, err
|
||||
}
|
||||
if transport.floated {
|
||||
// The relay can deliver ESP before the marked IKE response on
|
||||
// the same UDP/4500 association. Do not accept it as IKE, and
|
||||
// do not abort the exchange; keep waiting within the original
|
||||
// deadline.
|
||||
if !hasNonESPMarker(payload) {
|
||||
continue
|
||||
}
|
||||
@@ -636,7 +730,7 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
||||
return len(payload), nil
|
||||
}
|
||||
}
|
||||
return roundTripDatagram(ctx, transport.config.Timeout, write, read, datagram)
|
||||
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, datagrams)
|
||||
}
|
||||
|
||||
func isNetworkTimeout(err error) bool {
|
||||
@@ -824,11 +918,34 @@ func ikeResponseMatchesRequest(
|
||||
}
|
||||
var zeroSPI [8]byte
|
||||
if request.ResponderSPI == zeroSPI {
|
||||
return response.ResponderSPI != zeroSPI
|
||||
if response.ResponderSPI != zeroSPI {
|
||||
return true
|
||||
}
|
||||
return response.Exchange == exchangeIKEInit &&
|
||||
response.MessageID == 0 &&
|
||||
ikeInitResponseHasCookie(packet)
|
||||
}
|
||||
return response.ResponderSPI == request.ResponderSPI
|
||||
}
|
||||
|
||||
func ikeInitResponseHasCookie(packet []byte) bool {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil || header.Exchange != exchangeIKEInit || header.MessageID != 0 {
|
||||
return false
|
||||
}
|
||||
payloads, err := parsePayloadChain(header.NextPayload, body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err == nil && kind == notifyCookie && len(data) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func marshalSOCKS5Datagram(remote *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||
if remote == nil || remote.IP == nil || remote.Port < 1 || remote.Port > 65535 {
|
||||
return nil, errors.New("ike: invalid SOCKS5 UDP destination")
|
||||
|
||||
@@ -145,6 +145,18 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
}.marshal([]byte("response"))
|
||||
cookieFirst, cookieBody, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookieResponse := ikeHeader{
|
||||
InitiatorSPI: requestHeader.InitiatorSPI,
|
||||
NextPayload: cookieFirst,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
}.marshal(cookieBody)
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 2048)
|
||||
@@ -160,6 +172,14 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
return
|
||||
}
|
||||
if !destination.IP.Equal(second.IP) {
|
||||
cookieWire, marshalErr := marshalSOCKS5Datagram(first, cookieResponse)
|
||||
if marshalErr == nil {
|
||||
_, marshalErr = relay.WriteToUDP(cookieWire, peer)
|
||||
}
|
||||
if marshalErr != nil {
|
||||
serverDone <- marshalErr
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
wire, marshalErr := marshalSOCKS5Datagram(second, response)
|
||||
@@ -186,6 +206,31 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIKEResponseMatchesCookieChallengeWithZeroResponderSPI(t *testing.T) {
|
||||
request := ikeHeader{
|
||||
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}
|
||||
first, body, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := ikeHeader{
|
||||
InitiatorSPI: request.InitiatorSPI,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
MessageID: 0,
|
||||
NextPayload: first,
|
||||
}.marshal(body)
|
||||
if !ikeResponseMatchesRequest(response, request) {
|
||||
t.Fatal("IKE COOKIE response with zero Responder SPI was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKS5RoundTripSkipsStaleAndESPDatagrams(t *testing.T) {
|
||||
relay, err := net.ListenUDP(
|
||||
"udp",
|
||||
|
||||
+14
-11
@@ -31,9 +31,10 @@ const (
|
||||
payloadDelete = 42
|
||||
payloadTSi = 44
|
||||
payloadTSr = 45
|
||||
payloadEncrypted = 46
|
||||
payloadCP = 47
|
||||
payloadEAP = 48
|
||||
payloadEncrypted = 46
|
||||
payloadCP = 47
|
||||
payloadEAP = 48
|
||||
payloadEncryptedFragment = 53
|
||||
|
||||
protocolIKE = 1
|
||||
protocolESP = 3
|
||||
@@ -55,14 +56,16 @@ const (
|
||||
dhMODP2048 = 14
|
||||
transformAttributeKeyLen = 14
|
||||
|
||||
notifyInitialContact = 16384
|
||||
notifyMOBIKESupported = 16396
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyDeviceIdentity = 41101
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
notifyInitialContact = 16384
|
||||
notifyMOBIKESupported = 16396
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyCookie = 16390
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyFragmentationSupported = 16430
|
||||
notifyDeviceIdentity = 41101
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -169,18 +169,6 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
||||
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
||||
epdg := payload{
|
||||
Type: payloadIDr,
|
||||
|
||||
@@ -116,7 +116,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
"P-Preferred-Service: "+mmtelServiceURN,
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"User-Agent: "+session.imsUserAgent(),
|
||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
|
||||
"Supported: 100rel, timer, replaces",
|
||||
"Session-Expires: 1800;refresher=uac",
|
||||
@@ -146,6 +146,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Info("IMS call started",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"direction", "outgoing",
|
||||
"identity_source", identitySource,
|
||||
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||
@@ -228,6 +230,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
} else {
|
||||
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"error", safeSIPDiagnostic(ackErr.Error()),
|
||||
@@ -368,6 +372,33 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Info("IMS incoming call received",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"caller", call.public.Number,
|
||||
"call_id", call.public.ID,
|
||||
)
|
||||
}
|
||||
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
|
||||
calledNumber := identityNumber(request.value("To"))
|
||||
if calledNumber == "" {
|
||||
calledNumber = session.identity.public
|
||||
}
|
||||
receivedCall := ReceivedCall{
|
||||
DeviceID: session.request.DeviceID,
|
||||
IMSI: session.request.Identity.IMSI,
|
||||
CallID: callID,
|
||||
Caller: number,
|
||||
Called: calledNumber,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = session.provider.config.OnIncomingCall(ctx, receivedCall)
|
||||
}()
|
||||
}
|
||||
response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
@@ -469,7 +500,7 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon
|
||||
"Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d ACK", call.cseq),
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"User-Agent: "+session.imsUserAgent(),
|
||||
"Content-Length: 0", "", "",
|
||||
)
|
||||
session.writeMu.Lock()
|
||||
@@ -547,7 +578,7 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
|
||||
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"User-Agent: "+session.imsUserAgent(),
|
||||
"Content-Length: 0", "", "",
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
|
||||
@@ -678,7 +709,7 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
||||
"Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
||||
"Supported: 100rel, timer",
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"User-Agent: "+session.imsUserAgent(),
|
||||
)
|
||||
if method != "CANCEL" {
|
||||
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
|
||||
@@ -740,6 +771,13 @@ func (session *Session) dialogContactHeader() string {
|
||||
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
|
||||
return ""
|
||||
}
|
||||
if session.imsRegisterOptions().ContactFormat == vowifi.IMSContactFormatGSMA {
|
||||
contact := "Contact: <sip:" + session.contactAddress() + `>;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
|
||||
if strings.TrimSpace(session.instanceID) != "" {
|
||||
contact += `;+sip.instance="<` + session.instanceID + `>"`
|
||||
}
|
||||
return contact
|
||||
}
|
||||
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
|
||||
if strings.TrimSpace(session.instanceID) != "" {
|
||||
contact += `;+sip.instance="<` + session.instanceID + `>"`
|
||||
@@ -780,25 +818,13 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
|
||||
}
|
||||
|
||||
func (session *Session) pAccessNetworkInfo() string {
|
||||
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
node := strings.TrimSpace(profile.PANINode)
|
||||
if node == "" {
|
||||
node = "000000000000"
|
||||
if session == nil {
|
||||
return ""
|
||||
}
|
||||
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||
value += ";country=" + country
|
||||
if session.paniResolved {
|
||||
return session.pani
|
||||
}
|
||||
return value + ";network-provided"
|
||||
}
|
||||
|
||||
func (session *Session) callUserAgent() string {
|
||||
if session != nil && session.provider != nil {
|
||||
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "vocat/1"
|
||||
return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger())
|
||||
}
|
||||
|
||||
func callResponseDiagnostic(response *sipResponse) string {
|
||||
@@ -824,6 +850,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
|
||||
return
|
||||
}
|
||||
session.provider.config.Logger.Info("IMS call response",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"diagnostic", diagnostic,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user