mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 07:43:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}
|
BUILD_TIME=${{ github.event.repository.updated_at }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|
||||||
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
|
- name: Verify ${{ matrix.platform }} runtime, QMI, and smart-card stack
|
||||||
run: |
|
run: |
|
||||||
docker run --rm --platform '${{ matrix.platform }}' \
|
docker run --rm --platform '${{ matrix.platform }}' \
|
||||||
vocat-smoke:${{ matrix.arch }} version
|
vocat-smoke:${{ matrix.arch }} version
|
||||||
docker run --rm --platform '${{ matrix.platform }}' \
|
docker run --rm --platform '${{ matrix.platform }}' \
|
||||||
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \
|
--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:
|
build-and-push:
|
||||||
needs: smoke
|
needs: smoke
|
||||||
|
|||||||
@@ -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 ----
|
# ---- Stage 3: minimal runtime ----
|
||||||
FROM alpine:3.20
|
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 && \
|
addgroup -S -g 1000 vocat && \
|
||||||
adduser -S -D -H -u 1000 -G vocat 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;
|
If matching kernel modules are unavailable, use a firmware that includes them;
|
||||||
never force-install kmods built for a different kernel.
|
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:
|
The installer:
|
||||||
|
|
||||||
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
|
- 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`.
|
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
|
||||||
|
|
||||||
USB SIM readers use the Linux PC/SC service. The one-click installer installs
|
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
|
VoCat keeps the reader visible in the add-device dialog and reports the missing
|
||||||
service or driver instead of silently hiding it.
|
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
|
## Configuration
|
||||||
|
|
||||||
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
|
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
|
||||||
@@ -344,7 +370,7 @@ cd web && npm run build
|
|||||||
## Thanks
|
## Thanks
|
||||||
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
|
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
|
||||||
- [Linux.do](https://linux.do) — An inspiring tech community
|
- [Linux.do](https://linux.do) — An inspiring tech community
|
||||||
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
|
- [iniwex5](https://github.com/iniwex5) — Style and Functionality Guidelines
|
||||||
|
|
||||||
## Buy me a coffee
|
## 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 without arguments would enter the menu).
|
||||||
vocat version Print the build version and exit.
|
vocat version Print the build version and exit.
|
||||||
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths.
|
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
|
Use --repair-dji-qmi on Linux to restore the factory-ID
|
||||||
DJI/Baiwang 2ca3:4006 QMI interface without changing NV.
|
DJI/Baiwang 2ca3:4006 AT/QMI interface bindings and wake
|
||||||
|
QMI without changing NV.
|
||||||
vocat carrier import-ipcc [flags] FILE.ipcc
|
vocat carrier import-ipcc [flags] FILE.ipcc
|
||||||
Convert an Apple carrier bundle into a reviewable VoCat
|
Convert an Apple carrier bundle into a reviewable VoCat
|
||||||
profile. Preview is the default; --install writes it to
|
profile. Preview is the default; --install writes it to
|
||||||
|
|||||||
+13
-10
@@ -33,14 +33,17 @@ type doctorReport struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type djiQMIRepairResult struct {
|
type djiQMIRepairResult struct {
|
||||||
USBName string `json:"usb_name"`
|
USBName string `json:"usb_name"`
|
||||||
Interface string `json:"interface"`
|
Interface string `json:"interface"`
|
||||||
USBDevice string `json:"usb_device"`
|
USBDevice string `json:"usb_device"`
|
||||||
OriginalDriver string `json:"original_driver,omitempty"`
|
OriginalDriver string `json:"original_driver,omitempty"`
|
||||||
ControlDevice string `json:"control_device"`
|
SerialInterfaces []string `json:"serial_interfaces,omitempty"`
|
||||||
NetworkInterface string `json:"network_interface,omitempty"`
|
SerialDevices []string `json:"serial_devices,omitempty"`
|
||||||
QMIProbe string `json:"qmi_probe"`
|
ATDevice string `json:"at_device,omitempty"`
|
||||||
Attempts int `json:"attempts"`
|
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 {
|
func runDoctor(args []string) error {
|
||||||
@@ -49,7 +52,7 @@ func runDoctor(args []string) error {
|
|||||||
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
|
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
|
||||||
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
|
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
|
||||||
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password")
|
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")
|
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
|
||||||
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
|
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
|
||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
@@ -79,7 +82,7 @@ func runDoctor(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("repair DJI QMI binding: %w", err)
|
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)
|
candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx)
|
||||||
|
|||||||
+194
-37
@@ -19,9 +19,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
djiVendorID = "2ca3"
|
djiVendorID = "2ca3"
|
||||||
djiProductID = "4006"
|
djiProductID = "4006"
|
||||||
djiQMIIndex = 4
|
djiFirstSerialIndex = 0
|
||||||
|
djiLastSerialIndex = 3
|
||||||
|
djiATIndex = 2
|
||||||
|
djiQMIIndex = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
type usbControlTransfer struct {
|
type usbControlTransfer struct {
|
||||||
@@ -35,8 +38,12 @@ type usbControlTransfer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) {
|
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 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)
|
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")
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
entries, err := os.ReadDir(usbRoot)
|
entries, err := os.ReadDir(usbRoot)
|
||||||
if err != nil {
|
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))
|
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)
|
result.OriginalDriver = usbInterfaceDriver(interfacePath)
|
||||||
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
|
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
|
||||||
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver)
|
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
|
interfaceDetached := false
|
||||||
restoreOriginal := func() {
|
restoreOriginal := func() {
|
||||||
@@ -128,7 +151,10 @@ func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot string) (result djiQMI
|
|||||||
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
||||||
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
|
_ = 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)
|
_ = 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
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
bindPath := filepath.Join(driversRoot, "qmi_wwan", "bind")
|
if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil {
|
||||||
if err := writeSysfs(bindPath, result.Interface); err != nil {
|
return result, err
|
||||||
newIDErr := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "new_id"), djiVendorID+" "+djiProductID)
|
|
||||||
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
|
|
||||||
return result, fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
|
|
||||||
}
|
|
||||||
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
|
|
||||||
if retryErr := writeSysfs(bindPath, result.Interface); retryErr != nil {
|
|
||||||
return result, fmt.Errorf("bind qmi_wwan to %s: %w", result.Interface, retryErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
|
|
||||||
return result, fmt.Errorf("interface %s driver is %q after qmi_wwan bind", result.Interface, driver)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
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)
|
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)
|
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)
|
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
|
||||||
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
||||||
|
probeContextErr := probeContext.Err()
|
||||||
cancelProbe()
|
cancelProbe()
|
||||||
result.QMIProbe = strings.TrimSpace(string(output))
|
result.QMIProbe = strings.TrimSpace(string(output))
|
||||||
if probeErr != nil {
|
if probeErr != nil {
|
||||||
if probeContext.Err() != nil {
|
if probeContextErr != nil {
|
||||||
probeErr = errors.Join(probeErr, probeContext.Err())
|
probeErr = errors.Join(probeErr, probeContextErr)
|
||||||
}
|
}
|
||||||
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
||||||
}
|
}
|
||||||
interfaceDetached = false
|
|
||||||
return result, nil
|
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 {
|
func assertUSBDTR(devicePath string, interfaceIndex int) error {
|
||||||
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
|
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"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) {
|
func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) {
|
||||||
attempts := 0
|
attempts := 0
|
||||||
result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+63
-2
@@ -237,12 +237,20 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var onIncomingCall func(context.Context, ims.ReceivedCall) error
|
||||||
|
|
||||||
vowifiManager, err := configureVoWiFiRuntime(
|
vowifiManager, err := configureVoWiFiRuntime(
|
||||||
startupContext,
|
startupContext,
|
||||||
logger,
|
logger,
|
||||||
database,
|
database,
|
||||||
deviceManager,
|
deviceManager,
|
||||||
cardReaders,
|
cardReaders,
|
||||||
|
func(ctx context.Context, call ims.ReceivedCall) error {
|
||||||
|
if onIncomingCall != nil {
|
||||||
|
return onIncomingCall(ctx, call)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
||||||
@@ -276,10 +284,24 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.StartLogRetentionLoop(pollContext, time.Minute)
|
||||||
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
||||||
handler.StartTelegramBot(pollContext)
|
handler.StartTelegramBot(pollContext)
|
||||||
handler.StartSMSNotificationDispatchers(pollContext)
|
handler.StartSMSNotificationDispatchers(pollContext)
|
||||||
|
go handler.StartCellularCallMonitor(pollContext)
|
||||||
handler.StartAutomaticTasks(pollContext)
|
handler.StartAutomaticTasks(pollContext)
|
||||||
|
|
||||||
serverConfig := func(handler http.Handler) *http.Server {
|
serverConfig := func(handler http.Handler) *http.Server {
|
||||||
@@ -575,6 +597,7 @@ func configureVoWiFiRuntime(
|
|||||||
database *store.Store,
|
database *store.Store,
|
||||||
deviceManager *device.Manager,
|
deviceManager *device.Manager,
|
||||||
cardReaders *pcsc.Service,
|
cardReaders *pcsc.Service,
|
||||||
|
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||||
) (*vowifiruntime.Manager, error) {
|
) (*vowifiruntime.Manager, error) {
|
||||||
mapper := integration.ATMapper{
|
mapper := integration.ATMapper{
|
||||||
Store: database,
|
Store: database,
|
||||||
@@ -630,7 +653,7 @@ func configureVoWiFiRuntime(
|
|||||||
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
||||||
adapter = nativeQMIAdapter
|
adapter = nativeQMIAdapter
|
||||||
}
|
}
|
||||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
|
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger, onIncomingCall)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -694,7 +717,7 @@ func protectVoWiFiStartupRadioWithRetry(
|
|||||||
physicalID string,
|
physicalID string,
|
||||||
attempts int,
|
attempts int,
|
||||||
delay time.Duration,
|
delay time.Duration,
|
||||||
) error {
|
) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
@@ -730,6 +753,7 @@ func newVoWiFiOrchestrator(
|
|||||||
database *store.Store,
|
database *store.Store,
|
||||||
adapter vowifiDeviceAdapter,
|
adapter vowifiDeviceAdapter,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
|
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||||
) (*vowifi.Orchestrator, error) {
|
) (*vowifi.Orchestrator, error) {
|
||||||
apn := deviceConfig.APN
|
apn := deviceConfig.APN
|
||||||
if apn == "" {
|
if apn == "" {
|
||||||
@@ -748,6 +772,7 @@ func newVoWiFiOrchestrator(
|
|||||||
// alternate transport only if no SIP response was observed.
|
// alternate transport only if no SIP response was observed.
|
||||||
Transport: "tcp",
|
Transport: "tcp",
|
||||||
AutoTransportFallback: true,
|
AutoTransportFallback: true,
|
||||||
|
OnIncomingCall: onIncomingCall,
|
||||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||||
extra, _ := json.Marshal(map[string]any{
|
extra, _ := json.Marshal(map[string]any{
|
||||||
"transport": "ims",
|
"transport": "ims",
|
||||||
@@ -759,6 +784,7 @@ func newVoWiFiOrchestrator(
|
|||||||
"service_center_timestamp": message.ServiceCenterTimestamp,
|
"service_center_timestamp": message.ServiceCenterTimestamp,
|
||||||
"raw_rpdu": message.RawRPDU,
|
"raw_rpdu": message.RawRPDU,
|
||||||
"raw_tpdu": message.RawTPDU,
|
"raw_tpdu": message.RawTPDU,
|
||||||
|
"decode_error": message.DecodeError,
|
||||||
})
|
})
|
||||||
partsTotal := 1
|
partsTotal := 1
|
||||||
if message.Concat != nil && message.Concat.Total > 0 {
|
if message.Concat != nil && message.Concat.Total > 0 {
|
||||||
@@ -823,6 +849,31 @@ func newVoWiFiOrchestrator(
|
|||||||
// acknowledged, otherwise the SMSC will keep retransmitting it.
|
// acknowledged, otherwise the SMSC will keep retransmitting it.
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("device %q IMS provider: %w", deviceConfig.ID, err)
|
return nil, fmt.Errorf("device %q IMS provider: %w", deviceConfig.ID, err)
|
||||||
@@ -1302,3 +1353,13 @@ func liftCardRegionBlock(
|
|||||||
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
"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:
|
volumes:
|
||||||
# SQLite database + persistent state.
|
# 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
|
- vocat-data:/opt/vocat/data
|
||||||
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
|
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
|
||||||
# devices added after the container starts.
|
# devices added after the container starts.
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ Quectel USB المدعومة (معرّف الشركة المصنعة USB `2c7c`)
|
|||||||
|
|
||||||
تُنشر صورة GHCR لـ `linux/amd64` و`linux/arm64`.
|
تُنشر صورة 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_*`. متغيرات البيئة لها الأولوية.
|
يقرأ 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`.
|
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
|
## 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.
|
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`.
|
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
|
## 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é.
|
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` 向けに公開されています。
|
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_*` 環境変数を適用します。環境変数が優先されます。
|
Vocat は `VOCAT_CONFIG` からオプションの JSON 設定ファイルを読み込み、次に `VOCAT_*` 環境変数を適用します。環境変数が優先されます。
|
||||||
|
|||||||
@@ -186,6 +186,11 @@ TUN, настройки сети и устройств, добавленных
|
|||||||
|
|
||||||
Образ GHCR публикуется для `linux/amd64` и `linux/arm64`.
|
Образ 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_*`. Переменные окружения имеют приоритет.
|
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。
|
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` 架构;
|
- 检测 `amd64`、`386`、`arm64` 或 `armv7` 架构;
|
||||||
@@ -168,6 +175,11 @@ docker run -d \
|
|||||||
|
|
||||||
GHCR 镜像发布为 `linux/amd64` 与 `linux/arm64`。
|
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 读卡器
|
||||||
|
|
||||||
USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支持的软件包管理器上
|
USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支持的软件包管理器上
|
||||||
@@ -175,6 +187,16 @@ USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支
|
|||||||
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
|
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
|
||||||
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。
|
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_*` 环境变量。环境变量优先级更高。
|
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ docker run -d \
|
|||||||
|
|
||||||
GHCR 映像發佈為 `linux/amd64` 與 `linux/arm64`。
|
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_*` 環境變數。環境變數優先級更高。
|
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 保留。前端构建用于验证新增表单与类型契约。
|
|
||||||
@@ -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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -14,9 +15,24 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
"vocat/internal/netguard"
|
"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
|
// 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
|
// network half of the LPA download flow: the host authenticates nothing itself
|
||||||
// (the eUICC does all certificate verification on-card); it only shuttles the
|
// (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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
|
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
|
||||||
}
|
}
|
||||||
|
roots, err := es9pRootCAs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &es9pClient{
|
return &es9pClient{
|
||||||
smdp: validated.Host,
|
smdp: validated.Host,
|
||||||
endpoint: validated,
|
endpoint: validated,
|
||||||
http: netguard.NewPublicHTTPClient(90*time.Second, true),
|
http: netguard.NewPublicHTTPClientWithRootCAs(90*time.Second, true, roots),
|
||||||
}, nil
|
}, 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
|
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
|
||||||
// own explanation (surfaced verbatim, as the reference implementation does).
|
// own explanation (surfaced verbatim, as the reference implementation does).
|
||||||
type es9pError struct {
|
type es9pError struct {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package device
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -12,6 +15,30 @@ import (
|
|||||||
"testing"
|
"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.
|
// newTestES9P routes an es9pClient at a throwaway TLS server.
|
||||||
func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
|
func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -548,6 +548,14 @@ func decodeGSM7(septets []byte) (string, error) {
|
|||||||
return result.String(), nil
|
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 {
|
type pduCursor struct {
|
||||||
data []byte
|
data []byte
|
||||||
index int
|
index int
|
||||||
@@ -758,13 +766,29 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
byteCount := (int(length) + 1) / 2
|
var byteCount int
|
||||||
|
var septetCount int
|
||||||
|
if toa&0x70 == 0x50 {
|
||||||
|
// 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field
|
||||||
|
// indicates the number of useful semi-octets (i.e. characters * 7 / 4, rounded up).
|
||||||
|
// The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2.
|
||||||
|
// However, some non-standard sources specify length as the direct count of septets
|
||||||
|
// (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes).
|
||||||
|
if length >= 7 {
|
||||||
|
byteCount = (int(length) + 1) / 2
|
||||||
|
septetCount = int(length) * 4 / 7
|
||||||
|
} else {
|
||||||
|
byteCount = (int(length)*7 + 7) / 8
|
||||||
|
septetCount = int(length)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
byteCount = (int(length) + 1) / 2
|
||||||
|
}
|
||||||
value, err := cursor.bytes(byteCount)
|
value, err := cursor.bytes(byteCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if toa&0x70 == 0x50 {
|
if toa&0x70 == 0x50 {
|
||||||
septetCount := int(length) * 4 / 7
|
|
||||||
septets, unpackErr := unpackSeptets(value, septetCount, 0)
|
septets, unpackErr := unpackSeptets(value, septetCount, 0)
|
||||||
if unpackErr != nil {
|
if unpackErr != nil {
|
||||||
return "", unpackErr
|
return "", unpackErr
|
||||||
@@ -852,7 +876,13 @@ func decodeUserData(
|
|||||||
message.Text = string(utf16.Decode(units))
|
message.Text = string(utf16.Decode(units))
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
// 8-bit (binary) user data has no portable text representation, so the
|
||||||
|
// raw payload bytes are rendered as uppercase hexadecimal after the user
|
||||||
|
// data header is stripped. This keeps the bubble non-empty and gives a
|
||||||
|
// faithful rendering of the delivered content rather than a blank "".
|
||||||
message.Encoding = SMSEncoding8BitPDU
|
message.Encoding = SMSEncoding8BitPDU
|
||||||
|
payload := data[headerBytes:]
|
||||||
|
message.Text = strings.ToUpper(hex.EncodeToString(payload))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,3 +263,80 @@ func TestParseCMGLPreservesUndecodableRecord(t *testing.T) {
|
|||||||
t.Fatalf("messages = %#v", messages)
|
t.Fatalf("messages = %#v", messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeAlphanumericTPAddress(t *testing.T) {
|
||||||
|
// "TEST" encoded as 4 GSM-7 septets packed into 4 bytes (non-standard septet count format: length=4).
|
||||||
|
cursor := &pduCursor{data: []byte{0x04, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
|
||||||
|
address, err := readTPAddress(cursor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readTPAddress error = %v", err)
|
||||||
|
}
|
||||||
|
if address != "TEST" {
|
||||||
|
t.Fatalf("readTPAddress = %q, want TEST", address)
|
||||||
|
}
|
||||||
|
if cursor.index != len(cursor.data) {
|
||||||
|
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeAlphanumericTPAddressStandard3GPP(t *testing.T) {
|
||||||
|
// "Google" (6 chars) encoded per 3GPP TS 23.040 §9.1.2.5:
|
||||||
|
// length = 0x0B (11 useful semi-octets), TOA = 0xD0 (Alphanumeric),
|
||||||
|
// 6 bytes payload: C7 F7 FB CC 2E 03
|
||||||
|
cursor := &pduCursor{data: []byte{0x0b, 0xd0, 0xc7, 0xf7, 0xfb, 0xcc, 0x2e, 0x03}}
|
||||||
|
address, err := readTPAddress(cursor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readTPAddress standard 3GPP error = %v", err)
|
||||||
|
}
|
||||||
|
if address != "Google" {
|
||||||
|
t.Fatalf("readTPAddress standard 3GPP = %q, want Google", address)
|
||||||
|
}
|
||||||
|
if cursor.index != len(cursor.data) {
|
||||||
|
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// "TEST" (4 chars) with standard 3GPP semi-octets (length = 0x08, 8 semi-octets -> 4 bytes)
|
||||||
|
cursorTest := &pduCursor{data: []byte{0x08, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
|
||||||
|
addressTest, err := readTPAddress(cursorTest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readTPAddress standard 3GPP TEST error = %v", err)
|
||||||
|
}
|
||||||
|
if addressTest != "TEST" {
|
||||||
|
t.Fatalf("readTPAddress standard 3GPP TEST = %q, want TEST", addressTest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeDeliverPDUWithAlphanumericSender(t *testing.T) {
|
||||||
|
// SMS-DELIVER with alphanumeric originator "VoCat" and empty user data.
|
||||||
|
// SMSC length=0, first octet=0x04, OA length=0x05, OA TON=0xD0,
|
||||||
|
// OA bytes pack "VoCat" (5 septets -> 5 bytes), PID=0x00, DCS=0x00,
|
||||||
|
// SCTS=7 bytes, UDL=0x00.
|
||||||
|
message, err := decodeSMSPDU("000405D0D6F7304C0700004210203040500000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decodeSMSPDU error = %v", err)
|
||||||
|
}
|
||||||
|
if message.From != "VoCat" {
|
||||||
|
t.Fatalf("From = %q, want VoCat", message.From)
|
||||||
|
}
|
||||||
|
if message.Direction != SMSDirectionReceived {
|
||||||
|
t.Fatalf("Direction = %q", message.Direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||||
|
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
||||||
|
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
|
||||||
|
// Built from the GSM-7 deliver vector by swapping the DCS to 0xF5
|
||||||
|
// and replacing the user data with three raw binary bytes.
|
||||||
|
message, err := decodeSMSPDU(
|
||||||
|
"000405912143F500F54210203040500003AABBCC",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode 8-bit: %v", err)
|
||||||
|
}
|
||||||
|
if message.Encoding != SMSEncoding8BitPDU ||
|
||||||
|
message.Text != "AABBCC" ||
|
||||||
|
message.RawUserData != "AABBCC" {
|
||||||
|
t.Fatalf("8-bit message = %#v", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import (
|
|||||||
const (
|
const (
|
||||||
djiVendorID = "2ca3"
|
djiVendorID = "2ca3"
|
||||||
dji4GProductID = "4006"
|
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 {
|
type SysFSDiscoverer struct {
|
||||||
SysRoot string
|
SysRoot string
|
||||||
DevRoot string
|
DevRoot string
|
||||||
@@ -83,7 +89,15 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||||
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
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]
|
state := devices[deviceName]
|
||||||
@@ -142,11 +156,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
assignQuectelPortRoles(state.candidate.Ports)
|
assignQuectelPortRoles(state.candidate.Ports)
|
||||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||||
if !state.candidate.HasATPort() {
|
if !state.candidate.HasATPort() {
|
||||||
// A bound QMI interface proves the modem is alive, but the snapshot,
|
// A modem without a usable AT port cannot be driven by vocat, but it
|
||||||
// SMS, USSD and eSIM (AT+CSIM) paths all require an AT port. A missing
|
// is far more useful to surface it with a discovery issue than to
|
||||||
// ttyUSB/ttyACM node almost always means the option/qcserial driver
|
// silently drop it: the operator sees the device is present and gets
|
||||||
// does not claim the serial interfaces (often a missing PID in its
|
// told why it is unusable. Two shapes land here:
|
||||||
// device-ID table), not that the module lacks an AT interface.
|
// * 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"
|
state.candidate.DiscoveryIssue = "at_port_missing"
|
||||||
}
|
}
|
||||||
result = append(result, state.candidate)
|
result = append(result, state.candidate)
|
||||||
@@ -168,6 +190,15 @@ func IsDJI4GUSB(vendorID, productID string) bool {
|
|||||||
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
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 {
|
type discoveredWWANDevice struct {
|
||||||
index string
|
index string
|
||||||
ports []Port
|
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) {
|
func mustWrite(t *testing.T, path, value string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mustMkdir(t, filepath.Dir(path))
|
mustMkdir(t, filepath.Dir(path))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package netguard
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"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
|
// rejects private/special-use destinations at dial time, and validates every
|
||||||
// redirect before following it.
|
// redirect before following it.
|
||||||
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
|
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 {
|
if timeout <= 0 {
|
||||||
timeout = 30 * time.Second
|
timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
@@ -60,6 +68,7 @@ func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client
|
|||||||
ExpectContinueTimeout: time.Second,
|
ExpectContinueTimeout: time.Second,
|
||||||
TLSClientConfig: &tls.Config{
|
TLSClientConfig: &tls.Config{
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
|
RootCAs: roots,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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...))
|
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) {
|
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||||
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
|
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
|
||||||
client, err := backend.dial(ctx)
|
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 err != nil {
|
||||||
if len(physical) > 0 {
|
if len(physical) > 0 {
|
||||||
for index := range physical {
|
for index := range physical {
|
||||||
@@ -61,6 +160,9 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(physical) > 0 && len(states) < len(physical) {
|
||||||
|
states = backend.waitForPCSCReaders(ctx, client, physical, states)
|
||||||
|
}
|
||||||
readers := make([]Reader, 0, len(states))
|
readers := make([]Reader, 0, len(states))
|
||||||
for _, state := range states {
|
for _, state := range states {
|
||||||
reader := Reader{
|
reader := Reader{
|
||||||
|
|||||||
@@ -67,17 +67,33 @@ func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader {
|
|||||||
readers[0] = enrichPCSCReader(readers[0], physical[0])
|
readers[0] = enrichPCSCReader(readers[0], physical[0])
|
||||||
return readers
|
return readers
|
||||||
}
|
}
|
||||||
seen := make(map[string]bool, len(readers))
|
matchedPhysical := make(map[string]bool, len(physical))
|
||||||
for i := range readers {
|
for i := range readers {
|
||||||
seen[readers[i].USBPath] = true
|
|
||||||
for _, usbReader := range physical {
|
for _, usbReader := range physical {
|
||||||
if readers[i].USBPath == usbReader.USBPath {
|
if readers[i].USBPath == usbReader.USBPath {
|
||||||
readers[i] = enrichPCSCReader(readers[i], usbReader)
|
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 {
|
for _, usbReader := range physical {
|
||||||
if !seen[usbReader.USBPath] {
|
if !matchedPhysical[usbReader.USBPath] {
|
||||||
readers = append(readers, usbReader)
|
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) {
|
func writeUSBTestFile(t *testing.T, path, value string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
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;+CMSS=7",
|
||||||
"AT+CSQ;D12345;",
|
"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)
|
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) {
|
func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
for _, command := range []string{
|
for _, command := range []string{
|
||||||
@@ -48,7 +78,7 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
|||||||
"AT+CIMI",
|
"AT+CIMI",
|
||||||
"AT+CCID",
|
"AT+CCID",
|
||||||
} {
|
} {
|
||||||
if err := validateATCommand(command); err != nil {
|
if err := validateATCommand(command, false); err != nil {
|
||||||
t.Errorf("validateATCommand(%q): %v", command, err)
|
t.Errorf("validateATCommand(%q): %v", command, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
callDeduplicationWindow = 60 * time.Second
|
||||||
|
cellularCallMonitorInterval = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
callDeduplicationMu sync.Mutex
|
||||||
|
callDeduplicationMap = make(map[string]time.Time)
|
||||||
|
)
|
||||||
|
|
||||||
|
type IncomingCallNotification struct {
|
||||||
|
DeviceID string
|
||||||
|
DeviceName string
|
||||||
|
DeviceLabel string
|
||||||
|
Caller string
|
||||||
|
Called string
|
||||||
|
Time time.Time
|
||||||
|
Environment string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) Title() string {
|
||||||
|
return "收到来电"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) Text() string {
|
||||||
|
envText := "VoWiFi"
|
||||||
|
if value.Environment == "cellular" {
|
||||||
|
envText = "基站直连"
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
"📞 收到来电",
|
||||||
|
"设备 " + value.DeviceLabel,
|
||||||
|
"来电号码 " + value.Caller,
|
||||||
|
"被呼号码 " + value.Called,
|
||||||
|
"时间 " + value.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
"网络 " + envText,
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) DetailText() string {
|
||||||
|
lines := strings.Split(value.Text(), "\n")
|
||||||
|
return strings.Join(lines[1:], "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSuppressDuplicateCall(key string, now time.Time, window time.Duration) bool {
|
||||||
|
callDeduplicationMu.Lock()
|
||||||
|
defer callDeduplicationMu.Unlock()
|
||||||
|
for k, t := range callDeduplicationMap {
|
||||||
|
if now.Sub(t) > window*2 {
|
||||||
|
delete(callDeduplicationMap, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastTime, exists := callDeduplicationMap[key]; exists {
|
||||||
|
if now.Sub(lastTime) < window {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callDeduplicationMap[key] = now
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyIncomingCall delivers an incoming call alert to all configured notification channels.
|
||||||
|
func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCallNotification) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
caller := strings.TrimSpace(notification.Caller)
|
||||||
|
if caller == "" {
|
||||||
|
caller = "未知号码"
|
||||||
|
}
|
||||||
|
notification.Caller = caller
|
||||||
|
|
||||||
|
called := strings.TrimSpace(notification.Called)
|
||||||
|
if called == "" {
|
||||||
|
called = "--"
|
||||||
|
}
|
||||||
|
notification.Called = called
|
||||||
|
|
||||||
|
if notification.Time.IsZero() {
|
||||||
|
notification.Time = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
||||||
|
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if notification.DeviceLabel == "" || notification.DeviceLabel == "--" {
|
||||||
|
if configured, err := s.store.Device(ctx, notification.DeviceID); err == nil {
|
||||||
|
notification.DeviceName = strings.TrimSpace(configured.Name)
|
||||||
|
notification.DeviceLabel = firstNonEmpty(configured.Name, configured.ID, "--")
|
||||||
|
} else {
|
||||||
|
notification.DeviceLabel = firstNonEmpty(notification.DeviceID, "--")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destCtx := s.notificationDestinationContext(ctx)
|
||||||
|
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||||
|
setting, err := s.store.NotificationSetting(destCtx, channel)
|
||||||
|
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("read incoming call notification setting", "channel", channel, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var config map[string]any
|
||||||
|
if err := json.Unmarshal(setting.Config, &config); err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("decode incoming call notification setting", "channel", channel, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCallNotification(ctx context.Context, channel string, config map[string]any, message IncomingCallNotification) error {
|
||||||
|
switch channel {
|
||||||
|
case "telegram":
|
||||||
|
return sendTelegramTextNotification(ctx, config, message.Text())
|
||||||
|
case "bark":
|
||||||
|
return sendBarkTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||||
|
case "email":
|
||||||
|
return sendEmailTextNotification(ctx, config, message.Title()+" - "+message.DeviceLabel, message.Text())
|
||||||
|
case "pushplus":
|
||||||
|
return sendPushplusTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||||
|
case "webhook":
|
||||||
|
return sendCallWebhookNotification(ctx, config, message)
|
||||||
|
case "wecom":
|
||||||
|
return sendWecomNotification(ctx, config, wecomCallValues(message))
|
||||||
|
case "lark":
|
||||||
|
return sendLarkNotification(ctx, config, larkCallValues(message))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCallWebhookTemplate(template string, message IncomingCallNotification) string {
|
||||||
|
rendered := message.Text()
|
||||||
|
if strings.TrimSpace(template) != "" {
|
||||||
|
replacements := map[string]string{
|
||||||
|
"{{text}}": rendered,
|
||||||
|
"{{content}}": message.DetailText(),
|
||||||
|
"{{event}}": "call.received",
|
||||||
|
"{{timestamp}}": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
"{{number}}": message.Caller,
|
||||||
|
"{{caller}}": message.Caller,
|
||||||
|
"{{called}}": message.Called,
|
||||||
|
"{{device_id}}": message.DeviceID,
|
||||||
|
"{{device_name}}": message.DeviceName,
|
||||||
|
"{{device_label}}": message.DeviceLabel,
|
||||||
|
"{{environment}}": message.Environment,
|
||||||
|
}
|
||||||
|
for placeholder, value := range replacements {
|
||||||
|
template = strings.ReplaceAll(template, placeholder, value)
|
||||||
|
}
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCallWebhookNotification(ctx context.Context, config map[string]any, message IncomingCallNotification) error {
|
||||||
|
template := configString(config, "text_template")
|
||||||
|
rendered := renderCallWebhookTemplate(template, message)
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"event": "call.received",
|
||||||
|
"message": rendered,
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"caller": message.Caller,
|
||||||
|
"called": message.Called,
|
||||||
|
"environment": message.Environment,
|
||||||
|
})
|
||||||
|
timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
|
||||||
|
client, err := restrictedHTTPClient(ctx, timeout, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
retries := configInt(config, "retry_max")
|
||||||
|
for _, destination := range configStrings(config, "urls") {
|
||||||
|
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var sendErr error
|
||||||
|
for attempt := 0; attempt <= retries; attempt++ {
|
||||||
|
request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||||
|
if requestErr != nil {
|
||||||
|
return fmt.Errorf("create call webhook notification request: %w", requestErr)
|
||||||
|
}
|
||||||
|
for name, value := range configStringMap(config, "headers") {
|
||||||
|
request.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("User-Agent", "vocat-call-notification/1")
|
||||||
|
if secret := configString(config, "secret"); secret != "" {
|
||||||
|
signature := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = signature.Write(payload)
|
||||||
|
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
|
||||||
|
}
|
||||||
|
sendErr = performNotificationRequest(client, request, false)
|
||||||
|
if sendErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sendErr != nil {
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wecomCallValues(message IncomingCallNotification) wecomTemplateValues {
|
||||||
|
return wecomTemplateValues{
|
||||||
|
"event": "call.received",
|
||||||
|
"title": message.Title(),
|
||||||
|
"message": message.Text(),
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": message.DetailText(),
|
||||||
|
"number": message.Caller,
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkCallValues(message IncomingCallNotification) larkTemplateValues {
|
||||||
|
return larkTemplateValues{
|
||||||
|
"event": "call.received",
|
||||||
|
"title": message.Title(),
|
||||||
|
"message": message.Text(),
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": message.DetailText(),
|
||||||
|
"number": message.Caller,
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCellularCallMonitor scans physical modems for incoming calls in cellular mode.
|
||||||
|
func (s *Server) StartCellularCallMonitor(ctx context.Context) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(cellularCallMonitorInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.pollCellularCalls(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pollCellularCalls(ctx context.Context) {
|
||||||
|
devices, err := s.store.ListDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, config := range devices {
|
||||||
|
if !config.NetworkEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// If VoWiFi is active, incoming calls are handled directly by SIP INVITE in real time.
|
||||||
|
if s.callTransport(config.ID) == "vowifi" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, physicalID, present := s.physicalForConfig(config)
|
||||||
|
if !present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pollCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
response, err := s.devices.ExecuteAT(pollCtx, physicalID, "AT+CLCC")
|
||||||
|
cancel()
|
||||||
|
if err != nil || !response.OK() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
calls := parseCLCC(response)
|
||||||
|
for _, call := range calls {
|
||||||
|
direction, _ := call["direction"].(int)
|
||||||
|
state, _ := call["state"].(int)
|
||||||
|
// direction 1 = incoming (Mobile Terminated)
|
||||||
|
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
|
||||||
|
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
|
||||||
|
caller, _ := call["number"].(string)
|
||||||
|
if caller == "" {
|
||||||
|
caller = "未知号码"
|
||||||
|
}
|
||||||
|
called := ""
|
||||||
|
if entry.Snapshot != nil {
|
||||||
|
called = entry.Snapshot.Phone.Number
|
||||||
|
}
|
||||||
|
s.NotifyIncomingCall(ctx, IncomingCallNotification{
|
||||||
|
DeviceID: config.ID,
|
||||||
|
DeviceName: strings.TrimSpace(config.Name),
|
||||||
|
DeviceLabel: firstNonEmpty(config.Name, config.ID, "--"),
|
||||||
|
Caller: caller,
|
||||||
|
Called: firstNonEmpty(called, "--"),
|
||||||
|
Time: time.Now().UTC(),
|
||||||
|
Environment: "cellular",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||||
|
notification := IncomingCallNotification{
|
||||||
|
DeviceID: "ec20-1",
|
||||||
|
DeviceName: "Main Router",
|
||||||
|
DeviceLabel: "Main Router",
|
||||||
|
Caller: "+8613800138000",
|
||||||
|
Called: "+8613900139000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "vowifi",
|
||||||
|
}
|
||||||
|
|
||||||
|
if notification.Title() != "收到来电" {
|
||||||
|
t.Errorf("Title() = %q, want '收到来电'", notification.Title())
|
||||||
|
}
|
||||||
|
|
||||||
|
text := notification.Text()
|
||||||
|
for _, want := range []string{
|
||||||
|
"📞 收到来电",
|
||||||
|
"设备 Main Router",
|
||||||
|
"来电号码 +861380138000"[:10],
|
||||||
|
"被呼号码 +8613900139000",
|
||||||
|
"网络 VoWiFi",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("Text() omitted %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.Environment = "cellular"
|
||||||
|
if !strings.Contains(notification.Text(), "网络 基站直连") {
|
||||||
|
t.Errorf("Text() in cellular mode omitted '网络 基站直连':\n%s", notification.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncomingCallDeduplication(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
key := "test-device:+8613800000000"
|
||||||
|
|
||||||
|
// First call should not be suppressed
|
||||||
|
if shouldSuppressDuplicateCall(key, now, time.Minute) {
|
||||||
|
t.Fatal("first call unexpectedly suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate duplicate should be suppressed
|
||||||
|
if !shouldSuppressDuplicateCall(key, now.Add(5*time.Second), time.Minute) {
|
||||||
|
t.Fatal("duplicate call within window was not suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call after window should be allowed
|
||||||
|
if shouldSuppressDuplicateCall(key, now.Add(70*time.Second), time.Minute) {
|
||||||
|
t.Fatal("call after window was suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCallWebhookTemplate(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||||
|
message := IncomingCallNotification{
|
||||||
|
DeviceID: "dev-1",
|
||||||
|
DeviceName: "Living Room",
|
||||||
|
DeviceLabel: "EC20",
|
||||||
|
Caller: "+8613800000000",
|
||||||
|
Called: "+8613900000000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "vowifi",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := renderCallWebhookTemplate("{{event}}|{{device_id}}|{{device_name}}|{{device_label}}|{{caller}}|{{called}}|{{environment}}", message)
|
||||||
|
want := "call.received|dev-1|Living Room|EC20|+8613800000000|+8613900000000|vowifi"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderCallWebhookTemplate() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWecomAndLarkCallValues(t *testing.T) {
|
||||||
|
location := time.FixedZone("UTC+8", 8*60*60)
|
||||||
|
now := time.Date(2026, 8, 20, 18, 0, 0, 0, location)
|
||||||
|
message := IncomingCallNotification{
|
||||||
|
DeviceID: "dev-1",
|
||||||
|
DeviceName: "Office",
|
||||||
|
DeviceLabel: "EC20-Office",
|
||||||
|
Caller: "+8613800138000",
|
||||||
|
Called: "+8613900139000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "cellular",
|
||||||
|
}
|
||||||
|
|
||||||
|
wecom := wecomCallValues(message)
|
||||||
|
if wecom["event"] != "call.received" || wecom["title"] != "收到来电" || wecom["number"] != "+8613800138000" {
|
||||||
|
t.Fatalf("wecomCallValues = %#v", wecom)
|
||||||
|
}
|
||||||
|
if !strings.Contains(wecom["message"], "网络 基站直连") {
|
||||||
|
t.Fatalf("wecomCallValues message omitted network: %s", wecom["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
lark := larkCallValues(message)
|
||||||
|
if lark["event"] != "call.received" || lark["title"] != "收到来电" || lark["device_label"] != "EC20-Office" {
|
||||||
|
t.Fatalf("larkCallValues = %#v", lark)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -581,7 +581,7 @@ func (s *Server) handleDevicePath(
|
|||||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s.handleUSSD(w, r, physicalID)
|
return s.handleUSSD(w, r, config, physicalID)
|
||||||
case "actions/ussd/continue":
|
case "actions/ussd/continue":
|
||||||
return s.handleUSSDContinue(w, r)
|
return s.handleUSSDContinue(w, r)
|
||||||
case "actions/ussd/cancel":
|
case "actions/ussd/cancel":
|
||||||
@@ -1045,13 +1045,14 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
|||||||
var request struct {
|
var request struct {
|
||||||
Command string `json:"cmd"`
|
Command string `json:"cmd"`
|
||||||
TimeoutMs int `json:"timeout_ms"`
|
TimeoutMs int `json:"timeout_ms"`
|
||||||
|
Force bool `json:"force"`
|
||||||
}
|
}
|
||||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
command := strings.TrimSpace(request.Command)
|
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())
|
writeError(w, http.StatusBadRequest, "unsafe_at_command", err.Error())
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1100,7 +1101,7 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateATCommand(command string) error {
|
func validateATCommand(command string, force bool) error {
|
||||||
upper := strings.ToUpper(command)
|
upper := strings.ToUpper(command)
|
||||||
if len(command) < 2 || len(command) > 512 || !strings.HasPrefix(upper, "AT") {
|
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")
|
return errors.New("AT command must start with AT and contain at most 512 characters")
|
||||||
@@ -1108,6 +1109,9 @@ func validateATCommand(command string) error {
|
|||||||
if strings.ContainsAny(command, "\r\n\x00") {
|
if strings.ContainsAny(command, "\r\n\x00") {
|
||||||
return errors.New("AT command must contain exactly one line")
|
return errors.New("AT command must contain exactly one line")
|
||||||
}
|
}
|
||||||
|
if force {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
canonical := strings.NewReplacer(" ", "", "\t", "").Replace(upper)
|
canonical := strings.NewReplacer(" ", "", "\t", "").Replace(upper)
|
||||||
for _, blocked := range []string{
|
for _, blocked := range []string{
|
||||||
`+QCFG="USBNET"`,
|
`+QCFG="USBNET"`,
|
||||||
@@ -1138,7 +1142,34 @@ func validateATCommand(command string) error {
|
|||||||
return nil
|
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) {
|
if !requireMethod(w, r, http.MethodPost) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1152,21 +1183,61 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) b
|
|||||||
}
|
}
|
||||||
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
// 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)
|
result, err := s.devices.USSD(ctx, id, request.Command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeDeviceError(w, err)
|
s.writeDeviceError(w, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeUSSDResult(w, result)
|
||||||
"data": map[string]any{
|
|
||||||
"result": result.Text,
|
|
||||||
"raw": result.Raw,
|
|
||||||
"dcs": result.DCS,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return true
|
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 {
|
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
|
||||||
if !requireMethod(w, r, http.MethodPatch) {
|
if !requireMethod(w, r, http.MethodPatch) {
|
||||||
return true
|
return true
|
||||||
@@ -1636,7 +1707,14 @@ func (s *Server) configuredDeviceOverview(
|
|||||||
result["id"] = config.ID
|
result["id"] = config.ID
|
||||||
result["name"] = config.Name
|
result["name"] = config.Name
|
||||||
result["interface"] = config.Interface
|
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["audio_device"] = config.AudioDevice
|
||||||
result["backend_mode"] = config.DeviceBackend
|
result["backend_mode"] = config.DeviceBackend
|
||||||
result["control_device"] = config.ControlDevice
|
result["control_device"] = config.ControlDevice
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// overviewStreamInterval is the cadence at which the overview SSE stream pushes
|
// 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)
|
input := firstNonEmpty(request.Input, request.Command)
|
||||||
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
||||||
defer cancel()
|
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)
|
result, err := s.devices.ContinueUSSD(ctx, sessionID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeDeviceError(w, err)
|
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")
|
writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required")
|
||||||
return true
|
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 {
|
if err := s.devices.CancelUSSD(r.Context(), sessionID); err != nil {
|
||||||
s.writeDeviceError(w, err)
|
s.writeDeviceError(w, err)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"vocat/internal/modem"
|
"vocat/internal/modem"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/update"
|
"vocat/internal/update"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
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) {
|
func TestHandleCardPoliciesListsAll(t *testing.T) {
|
||||||
database, err := store.Open(context.Background(), ":memory:")
|
database, err := store.Open(context.Background(), ":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
||||||
for _, snapshot := range []*device.Snapshot{
|
for _, snapshot := range []*device.Snapshot{
|
||||||
{IMEI: "867123456789012"},
|
{IMEI: "867123456789012"},
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Server struct {
|
|||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
devices DeviceController
|
devices DeviceController
|
||||||
vowifi VoWiFiController
|
vowifi VoWiFiController
|
||||||
|
ussdSessions ussdSessionStore
|
||||||
logs *loghub.Hub
|
logs *loghub.Hub
|
||||||
assets fs.FS
|
assets fs.FS
|
||||||
indexHTML []byte
|
indexHTML []byte
|
||||||
@@ -117,6 +118,7 @@ func New(options Options) (*Server, error) {
|
|||||||
auth: options.Auth,
|
auth: options.Auth,
|
||||||
devices: options.Devices,
|
devices: options.Devices,
|
||||||
vowifi: options.VoWiFi,
|
vowifi: options.VoWiFi,
|
||||||
|
ussdSessions: newUSSDSessionStore(),
|
||||||
logs: options.Logs,
|
logs: options.Logs,
|
||||||
assets: options.Assets,
|
assets: options.Assets,
|
||||||
indexHTML: indexHTML,
|
indexHTML: indexHTML,
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ func validateNotificationField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if name == "proxy" && value != "" {
|
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)
|
return fmt.Errorf("%s is not a valid HTTP URL", field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -989,7 +989,7 @@ func validateOutboundURL(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) {
|
func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||||
parsed, err := parseOutboundURL(raw, false)
|
parsed, err := parseProxyURL(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -999,6 +999,26 @@ func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, er
|
|||||||
return parsed, nil
|
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) {
|
func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||||
|
|||||||
@@ -420,6 +420,11 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
|||||||
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
||||||
code: "invalid_notification_config",
|
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",
|
name: "null body",
|
||||||
body: `null`,
|
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) {
|
func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil)
|
||||||
|
|||||||
@@ -1981,7 +1981,7 @@ func (bot *telegramBot) handleATCommand(ctx context.Context, config telegramRunt
|
|||||||
|
|
||||||
func (bot *telegramBot) executeATCommand(ctx context.Context, deviceID, command string) (string, error) {
|
func (bot *telegramBot) executeATCommand(ctx context.Context, deviceID, command string) (string, error) {
|
||||||
command = strings.TrimSpace(command)
|
command = strings.TrimSpace(command)
|
||||||
if err := validateATCommand(command); err != nil {
|
if err := validateATCommand(command, false); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, _, physicalID, err := bot.device(deviceID)
|
_, _, physicalID, err := bot.device(deviceID)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ussdSessionStore is the HTTP-layer counterpart of device.Manager's USSD
|
||||||
|
// session map. A USSI awaiting-input reply opens a token here so the existing
|
||||||
|
// continue/cancel endpoints keep working; the token only records which device
|
||||||
|
// the dialog belongs to — the IMS session owns the actual network dialog.
|
||||||
|
type ussdSessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]ussdServerSession
|
||||||
|
}
|
||||||
|
|
||||||
|
type ussdServerSession struct {
|
||||||
|
deviceID string
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUSSDSessionStore() ussdSessionStore {
|
||||||
|
return ussdSessionStore{sessions: make(map[string]ussdServerSession)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *ussdSessionStore) open(deviceID string) string {
|
||||||
|
var token [8]byte
|
||||||
|
_, _ = rand.Read(token[:])
|
||||||
|
id := hex.EncodeToString(token[:])
|
||||||
|
store.mu.Lock()
|
||||||
|
if store.sessions == nil {
|
||||||
|
store.sessions = make(map[string]ussdServerSession)
|
||||||
|
}
|
||||||
|
store.sessions[id] = ussdServerSession{deviceID: deviceID, createdAt: time.Now().UTC()}
|
||||||
|
store.mu.Unlock()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *ussdSessionStore) device(sessionID string) (string, error) {
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
session, ok := store.sessions[strings.TrimSpace(sessionID)]
|
||||||
|
if !ok {
|
||||||
|
return "", device.ErrUSSDSessionNotFound
|
||||||
|
}
|
||||||
|
return session.deviceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *ussdSessionStore) drop(sessionID string) {
|
||||||
|
store.mu.Lock()
|
||||||
|
delete(store.sessions, strings.TrimSpace(sessionID))
|
||||||
|
store.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -28,38 +28,61 @@ const (
|
|||||||
// protocol layers consume this common result so their carrier handling cannot
|
// protocol layers consume this common result so their carrier handling cannot
|
||||||
// drift into separate MCC/MNC switch statements.
|
// drift into separate MCC/MNC switch statements.
|
||||||
type CarrierProfile struct {
|
type CarrierProfile struct {
|
||||||
ID string
|
ID string
|
||||||
MatchSource string
|
MatchSource string
|
||||||
RouteMCC string
|
RouteMCC string
|
||||||
RouteMNC string
|
RouteMNC string
|
||||||
EPDG string
|
EPDG string
|
||||||
IKEProposal string
|
IKEProposal string
|
||||||
AdvertiseEAPOnly bool
|
AdvertiseEAPOnly bool
|
||||||
IMSTransport string
|
AllowSMSWithoutContactConfirmation bool
|
||||||
IMSIdentityProfile string
|
IMSRegisterOptions IMSRegisterOptions
|
||||||
IMSRegisterProfile string
|
IMSTransport string
|
||||||
IMSIPSecEncryption string
|
IMSIdentityProfile string
|
||||||
SMSCenter string
|
IMSRegisterProfile string
|
||||||
PANICountry string
|
IMSIPSecEncryption string
|
||||||
PANINode string
|
SMSCenter string
|
||||||
IMSDialURIScheme string
|
PANICountry string
|
||||||
IMSUserEqPhone bool
|
PANINode string
|
||||||
IMSVoiceCodecs []string
|
IMSDialURIScheme string
|
||||||
|
IMSUserEqPhone bool
|
||||||
|
IMSVoiceCodecs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMSRegisterOptions carries carrier-specific SIP REGISTER header values.
|
||||||
|
// Pointer fields distinguish "use default" (nil) from "explicitly omit" ("").
|
||||||
|
type IMSRegisterOptions struct {
|
||||||
|
ContactFormat string
|
||||||
|
ExpirySeconds int
|
||||||
|
ContactExtraTags []string
|
||||||
|
SupportedHeader *string
|
||||||
|
AllowHeader *string
|
||||||
|
UserAgent string
|
||||||
|
PPreferredIdentity bool
|
||||||
|
PVisitedNetworkID string
|
||||||
|
PAccessNetworkInfo *string
|
||||||
|
CellularNetworkInfo string
|
||||||
|
AcceptContactTags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
IMSContactFormatStandard = "standard"
|
||||||
|
IMSContactFormatATT = "att"
|
||||||
|
)
|
||||||
|
|
||||||
type carrierProfileDocument struct {
|
type carrierProfileDocument struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Profiles []carrierProfileRule `json:"profiles"`
|
Profiles []carrierProfileRule `json:"profiles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileRule struct {
|
type carrierProfileRule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Match carrierProfileMatch `json:"match,omitzero"`
|
Match carrierProfileMatch `json:"match,omitzero"`
|
||||||
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
||||||
Route carrierProfileRoute `json:"route,omitzero"`
|
Route carrierProfileRoute `json:"route,omitzero"`
|
||||||
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
||||||
IKE carrierProfileIKE `json:"ike,omitzero"`
|
IKE carrierProfileIKE `json:"ike,omitzero"`
|
||||||
IMS carrierProfileIMS `json:"ims,omitzero"`
|
IMS carrierProfileIMS `json:"ims,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileMatch struct {
|
type carrierProfileMatch struct {
|
||||||
@@ -88,16 +111,32 @@ type carrierProfileIKE struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileIMS struct {
|
type carrierProfileIMS struct {
|
||||||
Transport string `json:"transport,omitempty"`
|
Transport string `json:"transport,omitempty"`
|
||||||
IdentityProfile string `json:"identity_profile,omitempty"`
|
IdentityProfile string `json:"identity_profile,omitempty"`
|
||||||
RegisterProfile string `json:"register_profile,omitempty"`
|
RegisterProfile string `json:"register_profile,omitempty"`
|
||||||
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
||||||
SMSCenter string `json:"sms_center,omitempty"`
|
SMSCenter string `json:"sms_center,omitempty"`
|
||||||
PANICountry string `json:"pani_country,omitempty"`
|
PANICountry string `json:"pani_country,omitempty"`
|
||||||
PANINode string `json:"pani_node,omitempty"`
|
PANINode string `json:"pani_node,omitempty"`
|
||||||
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
||||||
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
||||||
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
||||||
|
RegisterOptions carrierProfileRegisterOptions `json:"register_options,omitzero"`
|
||||||
|
AllowSMSWithoutContactConfirmation *bool `json:"allow_sms_without_contact_confirmation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileRegisterOptions struct {
|
||||||
|
ContactFormat string `json:"contact_format,omitempty"`
|
||||||
|
ExpirySeconds int `json:"expiry_seconds,omitempty"`
|
||||||
|
ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
|
||||||
|
SupportedHeader *string `json:"supported_header,omitempty"`
|
||||||
|
AllowHeader *string `json:"allow_header,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
|
||||||
|
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
|
||||||
|
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
|
||||||
|
CellularNetworkInfo string `json:"cellular_network_info,omitempty"`
|
||||||
|
AcceptContactTags []string `json:"accept_contact_tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed carrier_profiles.json
|
//go:embed carrier_profiles.json
|
||||||
@@ -292,6 +331,34 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if rule.IMS.RegisterOptions.ExpirySeconds != 0 &&
|
||||||
|
(rule.IMS.RegisterOptions.ExpirySeconds < 60 || rule.IMS.RegisterOptions.ExpirySeconds > 86400) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
|
||||||
|
format != IMSContactFormatStandard && format != IMSContactFormatATT {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
|
||||||
|
if strings.ContainsAny(value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []*string{rule.IMS.RegisterOptions.SupportedHeader, rule.IMS.RegisterOptions.AllowHeader, rule.IMS.RegisterOptions.PAccessNetworkInfo} {
|
||||||
|
if value != nil && strings.ContainsAny(*value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
|
||||||
|
if strings.ContainsAny(value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range rule.IMS.RegisterOptions.AcceptContactTags {
|
||||||
|
if strings.ContainsAny(value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +411,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bestScore = score
|
bestScore = score
|
||||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
resolved = applyCarrierProfileRule(resolved, rule, source, identity)
|
||||||
}
|
}
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
@@ -374,16 +441,18 @@ func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int
|
|||||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
||||||
score := 0
|
score := 0
|
||||||
sources := make([]string, 0, 6)
|
sources := make([]string, 0, 6)
|
||||||
|
hasHomePLMNMatch := false
|
||||||
if len(match.HomePLMNs) > 0 {
|
if len(match.HomePLMNs) > 0 {
|
||||||
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
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
|
return canonicalPLMNValue(value) == wanted
|
||||||
}) {
|
}) {
|
||||||
return 0, "", false
|
score += 100
|
||||||
|
sources = append(sources, "hplmn")
|
||||||
|
hasHomePLMNMatch = true
|
||||||
}
|
}
|
||||||
score += 100
|
|
||||||
sources = append(sources, "hplmn")
|
|
||||||
}
|
}
|
||||||
|
hasSelectorMatch := false
|
||||||
for _, selector := range []struct {
|
for _, selector := range []struct {
|
||||||
name string
|
name string
|
||||||
weight int
|
weight int
|
||||||
@@ -400,27 +469,32 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
actual := strings.TrimSpace(selector.actual)
|
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)
|
prefix = strings.TrimSpace(prefix)
|
||||||
if selector.foldCase {
|
if selector.foldCase {
|
||||||
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
||||||
}
|
}
|
||||||
return strings.HasPrefix(actual, prefix)
|
return strings.HasPrefix(actual, prefix)
|
||||||
}) {
|
}) {
|
||||||
|
score += selector.weight
|
||||||
|
sources = append(sources, selector.name)
|
||||||
|
hasSelectorMatch = true
|
||||||
|
} else if !hasHomePLMNMatch {
|
||||||
return 0, "", false
|
return 0, "", false
|
||||||
}
|
}
|
||||||
score += selector.weight
|
|
||||||
sources = append(sources, selector.name)
|
|
||||||
}
|
}
|
||||||
if len(match.SPNs) > 0 {
|
if len(match.SPNs) > 0 {
|
||||||
spn := strings.TrimSpace(identity.SPN)
|
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)
|
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||||
}) {
|
}) {
|
||||||
return 0, "", false
|
score += 20
|
||||||
|
sources = append(sources, "spn")
|
||||||
|
hasSelectorMatch = true
|
||||||
}
|
}
|
||||||
score += 20
|
}
|
||||||
sources = append(sources, "spn")
|
if !hasHomePLMNMatch && !hasSelectorMatch {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
return score, strings.Join(sources, "+"), score > 0
|
return score, strings.Join(sources, "+"), score > 0
|
||||||
}
|
}
|
||||||
@@ -434,11 +508,43 @@ func matchesAny(values []string, match func(string) bool) bool {
|
|||||||
return false
|
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.ID = rule.ID
|
||||||
base.MatchSource = source
|
base.MatchSource = source
|
||||||
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
||||||
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
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))
|
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
||||||
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
||||||
base.IKEProposal = value
|
base.IKEProposal = value
|
||||||
@@ -470,6 +576,50 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
|
|||||||
if len(rule.IMS.VoiceCodecs) > 0 {
|
if len(rule.IMS.VoiceCodecs) > 0 {
|
||||||
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
||||||
}
|
}
|
||||||
|
if rule.IMS.AllowSMSWithoutContactConfirmation != nil {
|
||||||
|
base.AllowSMSWithoutContactConfirmation = *rule.IMS.AllowSMSWithoutContactConfirmation
|
||||||
|
}
|
||||||
|
base.IMSRegisterOptions = applyRegisterOptions(base.IMSRegisterOptions, rule.IMS.RegisterOptions)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOptions) IMSRegisterOptions {
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(rule.ContactFormat)); value != "" {
|
||||||
|
base.ContactFormat = value
|
||||||
|
}
|
||||||
|
if rule.ExpirySeconds != 0 {
|
||||||
|
base.ExpirySeconds = rule.ExpirySeconds
|
||||||
|
}
|
||||||
|
if len(rule.ContactExtraTags) > 0 {
|
||||||
|
base.ContactExtraTags = append([]string(nil), rule.ContactExtraTags...)
|
||||||
|
}
|
||||||
|
if rule.SupportedHeader != nil {
|
||||||
|
value := strings.TrimSpace(*rule.SupportedHeader)
|
||||||
|
base.SupportedHeader = &value
|
||||||
|
}
|
||||||
|
if rule.AllowHeader != nil {
|
||||||
|
value := strings.TrimSpace(*rule.AllowHeader)
|
||||||
|
base.AllowHeader = &value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.UserAgent); value != "" {
|
||||||
|
base.UserAgent = value
|
||||||
|
}
|
||||||
|
if rule.PPreferredIdentity {
|
||||||
|
base.PPreferredIdentity = true
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.PVisitedNetworkID); value != "" {
|
||||||
|
base.PVisitedNetworkID = value
|
||||||
|
}
|
||||||
|
if rule.PAccessNetworkInfo != nil {
|
||||||
|
value := strings.TrimSpace(*rule.PAccessNetworkInfo)
|
||||||
|
base.PAccessNetworkInfo = &value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.CellularNetworkInfo); value != "" {
|
||||||
|
base.CellularNetworkInfo = value
|
||||||
|
}
|
||||||
|
if len(rule.AcceptContactTags) > 0 {
|
||||||
|
base.AcceptContactTags = append([]string(nil), rule.AcceptContactTags...)
|
||||||
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,19 +686,154 @@ func IsATT310280(identity SIMIdentity) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
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
|
return identity
|
||||||
}
|
}
|
||||||
profile := ResolveCarrierProfile(identity)
|
|
||||||
switch {
|
if strings.TrimSpace(identity.ICCID) != "" {
|
||||||
case profile.EPDG != "":
|
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
|
||||||
identity.EPDG = profile.EPDG
|
imsiCountry := countryCodeForMCC(identity.HomeMCC)
|
||||||
case profile.RouteMCC != "":
|
iccidCountry := countryCodeForMCC(mcc)
|
||||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
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
|
return identity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countryCodeForMCC(mcc string) string {
|
||||||
|
switch strings.TrimSpace(mcc) {
|
||||||
|
case "515":
|
||||||
|
return "PH"
|
||||||
|
case "262":
|
||||||
|
return "DE"
|
||||||
|
case "204":
|
||||||
|
return "NL"
|
||||||
|
case "234", "235":
|
||||||
|
return "GB"
|
||||||
|
case "460":
|
||||||
|
return "CN"
|
||||||
|
case "454":
|
||||||
|
return "HK"
|
||||||
|
case "466", "467":
|
||||||
|
return "TW"
|
||||||
|
case "525":
|
||||||
|
return "SG"
|
||||||
|
case "440", "441":
|
||||||
|
return "JP"
|
||||||
|
case "450":
|
||||||
|
return "KR"
|
||||||
|
case "310", "311", "312", "313", "314", "315", "316":
|
||||||
|
return "US"
|
||||||
|
case "302":
|
||||||
|
return "CA"
|
||||||
|
case "505":
|
||||||
|
return "AU"
|
||||||
|
case "208":
|
||||||
|
return "FR"
|
||||||
|
case "214":
|
||||||
|
return "ES"
|
||||||
|
case "222":
|
||||||
|
return "IT"
|
||||||
|
case "228":
|
||||||
|
return "CH"
|
||||||
|
case "232":
|
||||||
|
return "AT"
|
||||||
|
case "206":
|
||||||
|
return "BE"
|
||||||
|
case "260":
|
||||||
|
return "PL"
|
||||||
|
case "520":
|
||||||
|
return "TH"
|
||||||
|
case "510":
|
||||||
|
return "ID"
|
||||||
|
case "502":
|
||||||
|
return "MY"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomePLMNFromICCID infers the home MCC/MNC from well-known global ICCID prefixes.
|
||||||
|
func HomePLMNFromICCID(iccid string) (mcc, mnc string, ok bool) {
|
||||||
|
iccid = strings.TrimSpace(iccid)
|
||||||
|
if len(iccid) < 6 || !strings.HasPrefix(iccid, "89") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
prefixes := []struct {
|
||||||
|
prefix string
|
||||||
|
mcc string
|
||||||
|
mnc string
|
||||||
|
}{
|
||||||
|
// Philippines
|
||||||
|
{"896366", "515", "66"}, // DITO
|
||||||
|
{"896302", "515", "02"}, // Globe
|
||||||
|
{"896303", "515", "03"}, // Smart
|
||||||
|
// Germany
|
||||||
|
{"894920", "262", "02"}, // Vodafone DE
|
||||||
|
{"894901", "262", "01"}, // Telekom DE
|
||||||
|
{"894902", "262", "03"}, // O2 DE
|
||||||
|
{"894903", "262", "03"},
|
||||||
|
{"894907", "262", "07"},
|
||||||
|
// United Kingdom
|
||||||
|
{"894410", "234", "15"}, // Vodafone UK
|
||||||
|
{"894415", "234", "15"},
|
||||||
|
{"894411", "234", "30"}, // EE
|
||||||
|
{"894430", "234", "30"},
|
||||||
|
{"894420", "234", "20"}, // Three UK
|
||||||
|
{"894421", "234", "10"}, // O2 UK
|
||||||
|
// Netherlands
|
||||||
|
{"8937204", "204", "04"}, // Vodafone NL
|
||||||
|
{"893104", "204", "04"},
|
||||||
|
{"893108", "204", "08"}, // KPN
|
||||||
|
{"893116", "204", "16"}, // Odido
|
||||||
|
// Hong Kong
|
||||||
|
{"8985201", "454", "00"}, // CSL
|
||||||
|
{"8985203", "454", "03"}, // 3 HK
|
||||||
|
{"898523", "454", "03"},
|
||||||
|
{"8985204", "454", "12"}, // CMHK
|
||||||
|
{"8985206", "454", "06"}, // SmarTone
|
||||||
|
// China
|
||||||
|
{"898600", "460", "00"}, // China Mobile
|
||||||
|
{"898602", "460", "00"},
|
||||||
|
{"898604", "460", "00"},
|
||||||
|
{"898607", "460", "00"},
|
||||||
|
{"898601", "460", "01"}, // China Unicom
|
||||||
|
{"898606", "460", "01"},
|
||||||
|
{"898609", "460", "01"},
|
||||||
|
{"898603", "460", "03"}, // China Telecom
|
||||||
|
{"898605", "460", "03"},
|
||||||
|
{"898611", "460", "03"},
|
||||||
|
// Taiwan
|
||||||
|
{"8988601", "466", "92"}, // Chunghwa
|
||||||
|
{"8988602", "466", "97"}, // Taiwan Mobile
|
||||||
|
{"8988603", "466", "01"}, // FarEasTone
|
||||||
|
// Singapore
|
||||||
|
{"896501", "525", "01"}, // Singtel
|
||||||
|
{"896502", "525", "05"}, // StarHub
|
||||||
|
{"896503", "525", "03"}, // M1
|
||||||
|
{"896504", "525", "10"}, // SIMBA
|
||||||
|
}
|
||||||
|
for _, entry := range prefixes {
|
||||||
|
if strings.HasPrefix(iccid, entry.prefix) {
|
||||||
|
return entry.mcc, entry.mnc, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||||
// ePDG whose authoritative DNS only exposes addresses to home-country
|
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||||
@@ -561,6 +846,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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,9 @@
|
|||||||
package vowifi
|
package vowifi
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
"testing"
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
@@ -69,79 +19,57 @@ func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfilePrefersConstrainedMVNO(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{
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||||
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
|
||||||
})
|
})
|
||||||
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
if !strings.Contains(profile.ID, "giffgaff") || profile.MatchSource != "hplmn+gid1" {
|
||||||
t.Fatalf("MVNO profile = %#v", profile)
|
t.Fatalf("giffgaff profile = %#v", profile)
|
||||||
}
|
|
||||||
if profile.MatchSource != "hplmn+imsi+iccid" {
|
|
||||||
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) {
|
func TestResolveCarrierProfileATT(t *testing.T) {
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
identity SIMIdentity
|
|
||||||
source string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Apple GID1 selector",
|
|
||||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
|
|
||||||
source: "hplmn+gid1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Android SPN selector",
|
|
||||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"},
|
|
||||||
source: "hplmn+spn",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(test.identity)
|
|
||||||
if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source {
|
|
||||||
t.Fatalf("giffgaff profile = %#v", profile)
|
|
||||||
}
|
|
||||||
if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone {
|
|
||||||
t.Fatalf("giffgaff IMS settings = %#v", profile)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
generic := ResolveCarrierProfile(SIMIdentity{
|
|
||||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10",
|
|
||||||
})
|
|
||||||
if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" {
|
|
||||||
t.Fatalf("generic O2 profile = %#v", generic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) {
|
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
|
||||||
HomeMCC: "234", HomeMNC: "33",
|
|
||||||
})
|
})
|
||||||
if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" {
|
if !strings.Contains(profile.ID, "att") {
|
||||||
t.Fatalf("EE-hosted profile = %#v", profile)
|
t.Fatalf("AT&T profile = %#v", profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
||||||
for _, mnc := range []string{"03", "003"} {
|
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
if profile.ID != CarrierProfileStandard {
|
||||||
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
t.Fatalf("profile = %q", profile.ID)
|
||||||
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
}
|
||||||
}
|
if profile.IMSRegisterOptions.ExpirySeconds != 0 {
|
||||||
}
|
t.Fatalf("standard expiry = %d", profile.IMSRegisterOptions.ExpirySeconds)
|
||||||
}
|
}
|
||||||
|
if profile.IMSRegisterOptions.ContactFormat != "" {
|
||||||
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
t.Fatalf("standard contact format = %q", profile.IMSRegisterOptions.ContactFormat)
|
||||||
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
}
|
||||||
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
if profile.IMSRegisterOptions.SupportedHeader != nil {
|
||||||
}
|
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
|
||||||
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
}
|
||||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
if profile.AllowSMSWithoutContactConfirmation {
|
||||||
|
t.Fatal("standard profile should require SMS contact confirmation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,82 @@ func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, erro
|
|||||||
return target, nil
|
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 {
|
func carrierBundleRoots(files []*zip.File) []string {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
@@ -425,11 +501,17 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
|||||||
}
|
}
|
||||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||||
case "GID1":
|
case "GID1":
|
||||||
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value))
|
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||||
|
match.GID1Prefixes = append(match.GID1Prefixes, trimmed)
|
||||||
|
}
|
||||||
case "GID2":
|
case "GID2":
|
||||||
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value))
|
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||||
|
match.GID2Prefixes = append(match.GID2Prefixes, trimmed)
|
||||||
|
}
|
||||||
case "ICCID":
|
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":
|
case "SPN":
|
||||||
match.SPNs = append(match.SPNs, value)
|
match.SPNs = append(match.SPNs, value)
|
||||||
default:
|
default:
|
||||||
@@ -437,16 +519,13 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
|||||||
return carrierProfileMatch{}, false, false
|
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 {
|
func trimAppleHexMask(value string) string {
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
trimmed := strings.TrimRight(value, "F")
|
return strings.TrimRight(value, "F")
|
||||||
if trimmed == "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
||||||
|
|||||||
+11609
-62
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ const (
|
|||||||
usimAIDPrefix = "A0000000871002"
|
usimAIDPrefix = "A0000000871002"
|
||||||
isimAIDPrefix = "A0000000871004"
|
isimAIDPrefix = "A0000000871004"
|
||||||
efADDecimal = 28589 // 0x6FAD
|
efADDecimal = 28589 // 0x6FAD
|
||||||
|
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
|
||||||
channelCleanupTimeout = 3 * time.Second
|
channelCleanupTimeout = 3 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -239,14 +240,79 @@ func (adapter *EC20Adapter) readHomePLMN(
|
|||||||
return mcc, mnc, nil
|
return mcc, mnc, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
|
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic.
|
||||||
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
|
|
||||||
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
||||||
return mcc, mnc, nil
|
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
|
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) {
|
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
||||||
assignments := []struct {
|
assignments := []struct {
|
||||||
prefix string
|
prefix string
|
||||||
|
|||||||
@@ -28,48 +28,67 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
|||||||
if resolver == nil {
|
if resolver == nil {
|
||||||
resolver = net.DefaultResolver
|
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), "."))
|
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
|
||||||
if subnet == "" {
|
validSystemAddresses := filterValidPublicEPDGAddresses(addresses)
|
||||||
if systemErr != nil {
|
if systemErr == nil && len(validSystemAddresses) > 0 {
|
||||||
return nil, systemErr
|
return validSystemAddresses, nil
|
||||||
}
|
|
||||||
return nil, errors.New("ePDG did not resolve to an IP address")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||||
client := &http.Client{Timeout: 8 * time.Second}
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
var fallbackErr error
|
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
|
hostsToTry := []string{normalized}
|
||||||
// address records. Stay inside the runtime's two-minute setup window and
|
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
||||||
// wait through one complete negative-cache TTL so a single reconnect is
|
hostsToTry = append(hostsToTry, alt)
|
||||||
// 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
|
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 {
|
if fallbackErr == nil && len(fallback) > 0 {
|
||||||
return fallback, nil
|
return fallback, nil
|
||||||
}
|
}
|
||||||
if attempt+1 < fallbackAttempts {
|
|
||||||
select {
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if systemErr == nil {
|
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)
|
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(
|
func resolveEPDGWithECS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *http.Client,
|
client *http.Client,
|
||||||
@@ -85,7 +104,9 @@ func resolveEPDGWithECS(
|
|||||||
query := parsed.Query()
|
query := parsed.Query()
|
||||||
query.Set("name", strings.TrimSpace(host))
|
query.Set("name", strings.TrimSpace(host))
|
||||||
query.Set("type", "A")
|
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()
|
parsed.RawQuery = query.Encode()
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||||
@@ -116,7 +137,7 @@ func resolveEPDGWithECS(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), "."))
|
ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), "."))
|
||||||
if ip == nil {
|
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
duplicate := false
|
duplicate := false
|
||||||
|
|||||||
@@ -188,34 +188,60 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
makeNotify(notifyNATSource, sourceHash),
|
makeNotify(notifyNATSource, sourceHash),
|
||||||
makeNotify(notifyNATDestination, destinationHash),
|
makeNotify(notifyNATDestination, destinationHash),
|
||||||
}
|
}
|
||||||
first, initBody, err := marshalPayloadChain(initPayloads)
|
var (
|
||||||
if err != nil {
|
initRequest []byte
|
||||||
return nil, err
|
initResponse []byte
|
||||||
}
|
responseHeader ikeHeader
|
||||||
initRequest := ikeHeader{
|
initResponsePayloads []payload
|
||||||
InitiatorSPI: initiatorSPI,
|
cookie []byte
|
||||||
NextPayload: first,
|
)
|
||||||
Exchange: exchangeIKEInit,
|
for attempt := 0; attempt < maxIKEInitCookieChallenges; attempt++ {
|
||||||
Flags: flagInitiator,
|
requestPayloads := append([]payload(nil), initPayloads...)
|
||||||
MessageID: 0,
|
if len(cookie) > 0 {
|
||||||
}.marshal(initBody)
|
requestPayloads = append([]payload{makeNotify(notifyCookie, cookie)}, requestPayloads...)
|
||||||
initResponse, err := transport.RoundTrip(ctx, initRequest)
|
}
|
||||||
if err != nil {
|
first, initBody, err := marshalPayloadChain(requestPayloads)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
responseHeader, responseBody, err := validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
|
}
|
||||||
if err != nil {
|
initRequest = ikeHeader{
|
||||||
return nil, err
|
InitiatorSPI: initiatorSPI,
|
||||||
}
|
NextPayload: first,
|
||||||
if responseHeader.ResponderSPI == [8]byte{} {
|
Exchange: exchangeIKEInit,
|
||||||
return nil, errors.New("ike: responder returned a zero SPI")
|
Flags: flagInitiator,
|
||||||
}
|
MessageID: 0,
|
||||||
initResponsePayloads, err := parsePayloadChain(responseHeader.NextPayload, responseBody)
|
}.marshal(initBody)
|
||||||
if err != nil {
|
initResponse, err = transport.RoundTrip(ctx, initRequest)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
if err := rejectFatalNotifications(initResponsePayloads); 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
|
||||||
}
|
}
|
||||||
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -616,6 +642,29 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
return session, nil
|
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(
|
func buildInitialEAPAuth(
|
||||||
idi payload,
|
idi payload,
|
||||||
requestedIDr payload,
|
requestedIDr payload,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,25 +18,6 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
|
|||||||
|
|
||||||
type constantReader struct{ value byte }
|
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) {
|
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
errNoProposalChosen,
|
errNoProposalChosen,
|
||||||
@@ -64,17 +47,20 @@ func (reader constantReader) Read(destination []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type firstAuthCaptureTransport struct {
|
type firstAuthCaptureTransport struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
wantEAPOnly bool
|
wantEAPOnly bool
|
||||||
wantGroup uint16
|
wantGroup uint16
|
||||||
calls int
|
calls int
|
||||||
suite negotiatedSuite
|
suite negotiatedSuite
|
||||||
keys ikeKeys
|
keys ikeKeys
|
||||||
spii [8]byte
|
spii [8]byte
|
||||||
spir [8]byte
|
spir [8]byte
|
||||||
nonceI []byte
|
nonceI []byte
|
||||||
nonceR []byte
|
nonceR []byte
|
||||||
floated bool
|
floated bool
|
||||||
|
cookieChallenge []byte
|
||||||
|
cookieSeen bool
|
||||||
|
cookieLoop bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
|
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
|
||||||
@@ -92,6 +78,24 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
|
|||||||
|
|
||||||
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
|
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
|
||||||
transport.calls++
|
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 {
|
switch transport.calls {
|
||||||
case 1:
|
case 1:
|
||||||
return transport.answerIKEInit(packet)
|
return transport.answerIKEInit(packet)
|
||||||
@@ -102,6 +106,69 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) != 3 {
|
||||||
|
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", 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) {
|
func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte, error) {
|
||||||
header, body, err := parseIKEPacket(packet)
|
header, body, err := parseIKEPacket(packet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,7 +189,7 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
|||||||
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
||||||
wantGroup := transport.wantGroup
|
wantGroup := transport.wantGroup
|
||||||
if wantGroup == 0 {
|
if wantGroup == 0 {
|
||||||
wantGroup = dhMODP1024
|
wantGroup = dhMODP2048
|
||||||
}
|
}
|
||||||
wantKELength := 128
|
wantKELength := 128
|
||||||
if wantGroup == dhMODP2048 {
|
if wantGroup == dhMODP2048 {
|
||||||
@@ -291,8 +358,12 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
func TestProviderRetriesIKEInitAfterCookie(t *testing.T) {
|
||||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
capture := &firstAuthCaptureTransport{
|
||||||
|
t: t,
|
||||||
|
wantEAPOnly: true,
|
||||||
|
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
|
||||||
|
}
|
||||||
provider, err := NewProvider(Config{
|
provider, err := NewProvider(Config{
|
||||||
Random: constantReader{value: 0x42},
|
Random: constantReader{value: 0x42},
|
||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
@@ -312,21 +383,65 @@ func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T
|
|||||||
}
|
}
|
||||||
aka := &testAKAProvider{}
|
aka := &testAKAProvider{}
|
||||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||||
DeviceID: "ec20-o2",
|
DeviceID: "ec20-cookie",
|
||||||
Identity: vowifi.SIMIdentity{
|
Identity: vowifi.SIMIdentity{
|
||||||
ICCID: "8949200000000000000",
|
ICCID: "8944100000000000000",
|
||||||
IMSI: "262030123456789",
|
IMSI: "234150123456789",
|
||||||
HomeMCC: "262",
|
HomeMCC: "234",
|
||||||
HomeMNC: "03",
|
HomeMNC: "15",
|
||||||
},
|
},
|
||||||
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
|
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
|
||||||
AKA: aka,
|
AKA: aka,
|
||||||
})
|
})
|
||||||
if !errors.Is(err, errFirstAuthObserved) {
|
if !errors.Is(err, errFirstAuthObserved) {
|
||||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
||||||
}
|
}
|
||||||
if capture.calls != 2 || capture.floated || aka.calls != 0 {
|
if capture.calls != 3 || !capture.cookieSeen || capture.floated || aka.calls != 0 {
|
||||||
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -576,10 +576,17 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
// 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 {
|
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var cookieResponse []byte
|
||||||
for _, candidate := range transport.remotes {
|
for _, candidate := range transport.remotes {
|
||||||
transport.remote = cloneUDPAddr(candidate)
|
transport.remote = cloneUDPAddr(candidate)
|
||||||
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
||||||
if attemptErr == nil {
|
if attemptErr == nil {
|
||||||
|
if ikeInitResponseHasCookie(response) {
|
||||||
|
if cookieResponse == nil {
|
||||||
|
cookieResponse = append([]byte(nil), response...)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
lastErr = attemptErr
|
lastErr = attemptErr
|
||||||
@@ -587,6 +594,9 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
return nil, 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 nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
||||||
}
|
}
|
||||||
return transport.roundTripLocked(ctx, packet, requestHeader)
|
return transport.roundTripLocked(ctx, packet, requestHeader)
|
||||||
@@ -824,11 +834,34 @@ func ikeResponseMatchesRequest(
|
|||||||
}
|
}
|
||||||
var zeroSPI [8]byte
|
var zeroSPI [8]byte
|
||||||
if request.ResponderSPI == zeroSPI {
|
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
|
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) {
|
func marshalSOCKS5Datagram(remote *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||||
if remote == nil || remote.IP == nil || remote.Port < 1 || remote.Port > 65535 {
|
if remote == nil || remote.IP == nil || remote.Port < 1 || remote.Port > 65535 {
|
||||||
return nil, errors.New("ike: invalid SOCKS5 UDP destination")
|
return nil, errors.New("ike: invalid SOCKS5 UDP destination")
|
||||||
|
|||||||
@@ -145,6 +145,18 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
|||||||
Exchange: exchangeIKEInit,
|
Exchange: exchangeIKEInit,
|
||||||
Flags: flagResponse,
|
Flags: flagResponse,
|
||||||
}.marshal([]byte("response"))
|
}.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)
|
serverDone := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
buffer := make([]byte, 2048)
|
buffer := make([]byte, 2048)
|
||||||
@@ -160,6 +172,14 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !destination.IP.Equal(second.IP) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
wire, marshalErr := marshalSOCKS5Datagram(second, response)
|
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) {
|
func TestSOCKS5RoundTripSkipsStaleAndESPDatagrams(t *testing.T) {
|
||||||
relay, err := net.ListenUDP(
|
relay, err := net.ListenUDP(
|
||||||
"udp",
|
"udp",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const (
|
|||||||
notifyMOBIKESupported = 16396
|
notifyMOBIKESupported = 16396
|
||||||
notifyNATSource = 16388
|
notifyNATSource = 16388
|
||||||
notifyNATDestination = 16389
|
notifyNATDestination = 16389
|
||||||
|
notifyCookie = 16390
|
||||||
notifyEAPOnlyAuth = 16417
|
notifyEAPOnlyAuth = 16417
|
||||||
notifyDeviceIdentity = 41101
|
notifyDeviceIdentity = 41101
|
||||||
notifyInvalidKE = 17
|
notifyInvalidKE = 17
|
||||||
|
|||||||
@@ -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) {
|
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
||||||
epdg := payload{
|
epdg := payload{
|
||||||
Type: payloadIDr,
|
Type: payloadIDr,
|
||||||
|
|||||||
@@ -368,6 +368,25 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
|
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)
|
response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = respond(response)
|
_ = respond(response)
|
||||||
|
|||||||
@@ -80,6 +80,53 @@ func TestIncomingCallCanBeRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIncomingCallTriggersOnIncomingCallCallback(t *testing.T) {
|
||||||
|
var captured ReceivedCall
|
||||||
|
called := make(chan struct{}, 1)
|
||||||
|
provider := &Provider{
|
||||||
|
config: Config{
|
||||||
|
OnIncomingCall: func(_ context.Context, call ReceivedCall) error {
|
||||||
|
captured = call
|
||||||
|
called <- struct{}{}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
session := &Session{
|
||||||
|
provider: provider,
|
||||||
|
fromTag: "local-tag",
|
||||||
|
calls: make(map[string]*imsCall),
|
||||||
|
request: vowifi.IMSRequest{
|
||||||
|
DeviceID: "ec20-test",
|
||||||
|
Identity: vowifi.SIMIdentity{IMSI: "123456789012345"},
|
||||||
|
},
|
||||||
|
identity: identitySet{public: "sip:[email protected]"},
|
||||||
|
}
|
||||||
|
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||||
|
"INVITE sip:[email protected] SIP/2.0",
|
||||||
|
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-notify",
|
||||||
|
"From: <tel:+447700999888>;tag=caller-tag",
|
||||||
|
"To: <tel:+447700900123>",
|
||||||
|
"Call-ID: notify-call-id",
|
||||||
|
"CSeq: 1 INVITE",
|
||||||
|
"Content-Length: 0", "", "",
|
||||||
|
}, "\r\n")))
|
||||||
|
if err != nil || packet.Request == nil {
|
||||||
|
t.Fatalf("parse INVITE: %v", err)
|
||||||
|
}
|
||||||
|
session.handleCallRequest(packet.Request, func([]byte) error { return nil })
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-called:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("OnIncomingCall was not invoked within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
if captured.DeviceID != "ec20-test" || captured.Caller != "+447700999888" || captured.Called != "+447700900123" || captured.CallID != "notify-call-id" {
|
||||||
|
t.Fatalf("captured call = %#v", captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
|
func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
|
||||||
session := &Session{calls: make(map[string]*imsCall)}
|
session := &Session{calls: make(map[string]*imsCall)}
|
||||||
call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}}
|
call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}}
|
||||||
@@ -176,13 +223,13 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
|||||||
wire := <-wireResult
|
wire := <-wireResult
|
||||||
|
|
||||||
for _, expected := range []string{
|
for _, expected := range []string{
|
||||||
"INVITE sip:888@ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n",
|
"INVITE tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n",
|
||||||
"To: <sip:888@ims.mnc033.mcc234.3gppnetwork.org>\r\n",
|
"To: <tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org>\r\n",
|
||||||
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
||||||
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||||
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||||
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;country=GB;network-provided\r\n",
|
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided\r\n",
|
||||||
"User-Agent: VoCat Test\r\n",
|
"User-Agent: VoCat Test\r\n",
|
||||||
"Accept: application/sdp\r\n",
|
"Accept: application/sdp\r\n",
|
||||||
} {
|
} {
|
||||||
|
|||||||
+114
-60
@@ -66,11 +66,27 @@ type Config struct {
|
|||||||
// OnSMSStatus is invoked for an SMS-STATUS-REPORT received after a
|
// OnSMSStatus is invoked for an SMS-STATUS-REPORT received after a
|
||||||
// submission that requested a delivery report.
|
// submission that requested a delivery report.
|
||||||
OnSMSStatus func(context.Context, ReceivedSMSStatus) error
|
OnSMSStatus func(context.Context, ReceivedSMSStatus) error
|
||||||
|
// OnUSSD is invoked for a network-originated USSD MESSAGE received over
|
||||||
|
// IMS (3GPP TS 24.390). Returning an error is logged but does not affect
|
||||||
|
// the 200 OK already sent, because USSI has no RP-ACK transport.
|
||||||
|
OnUSSD func(context.Context, ReceivedUSSD) error
|
||||||
|
// OnIncomingCall is invoked when an incoming voice call (INVITE) is received over IMS.
|
||||||
|
OnIncomingCall func(context.Context, ReceivedCall) error
|
||||||
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
|
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
|
||||||
// not include message text or raw protocol payloads.
|
// not include message text or raw protocol payloads.
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReceivedCall is an incoming voice call event delivered over IMS.
|
||||||
|
type ReceivedCall struct {
|
||||||
|
DeviceID string
|
||||||
|
IMSI string
|
||||||
|
CallID string
|
||||||
|
Caller string
|
||||||
|
Called string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
|
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
|
||||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||||
// runtime dependency outside the Go standard library.
|
// runtime dependency outside the Go standard library.
|
||||||
@@ -704,10 +720,6 @@ func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
|||||||
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
||||||
}
|
}
|
||||||
|
|
||||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
|
||||||
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
|
|
||||||
}
|
|
||||||
|
|
||||||
func (session *Session) abort() {
|
func (session *Session) abort() {
|
||||||
session.refreshCancel()
|
session.refreshCancel()
|
||||||
_ = session.conn.Close()
|
_ = session.conn.Close()
|
||||||
@@ -911,9 +923,10 @@ func (session *Session) buildRegister(
|
|||||||
authorizationHeader string,
|
authorizationHeader string,
|
||||||
authorization string,
|
authorization string,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
att310280 := vowifi.IsATT310280(session.request.Identity)
|
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||||
if att310280 {
|
registerOptions := profile.IMSRegisterOptions
|
||||||
expires = 18400
|
if registerOptions.ExpirySeconds != 0 {
|
||||||
|
expires = registerOptions.ExpirySeconds
|
||||||
}
|
}
|
||||||
branch, err := randomHex(12)
|
branch, err := randomHex(12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -924,43 +937,25 @@ func (session *Session) buildRegister(
|
|||||||
transportUpper := strings.ToUpper(session.transport)
|
transportUpper := strings.ToUpper(session.transport)
|
||||||
requestURI := "sip:" + session.identity.domain
|
requestURI := "sip:" + session.identity.domain
|
||||||
routeURI := "sip:" + session.endpoint.address() + ";transport=" + session.transport + ";lr"
|
routeURI := "sip:" + session.endpoint.address() + ";transport=" + session.transport + ";lr"
|
||||||
contact := fmt.Sprintf(
|
contact := session.buildContact(contactAddress, registerOptions)
|
||||||
"<sip:%s@%s;transport=%s>;+sip.instance=\"<%s>\";+g.3gpp.smsip;audio;"+
|
|
||||||
`+g.3gpp.icsi-ref="%s"`,
|
defaultSupported := "path, gruu"
|
||||||
session.identity.user,
|
defaultAllow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, SUBSCRIBE, NOTIFY"
|
||||||
contactAddress,
|
supported := defaultSupported
|
||||||
session.transport,
|
if registerOptions.SupportedHeader != nil {
|
||||||
session.instanceID,
|
supported = *registerOptions.SupportedHeader
|
||||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
|
||||||
)
|
|
||||||
if att310280 {
|
|
||||||
contact = fmt.Sprintf(
|
|
||||||
`<sip:%s@%s;transport=%s>;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+
|
|
||||||
`+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
|
|
||||||
session.identity.user,
|
|
||||||
contactAddress,
|
|
||||||
session.transport,
|
|
||||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
|
||||||
session.instanceID,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
allow := defaultAllow
|
||||||
supported := "path, gruu"
|
if registerOptions.AllowHeader != nil {
|
||||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, SUBSCRIBE, NOTIFY"
|
allow = *registerOptions.AllowHeader
|
||||||
if o2Germany {
|
|
||||||
// Match the complete IMS capability set used by the previously working
|
|
||||||
// VoHive client. O2 validates more of the initial UE security profile
|
|
||||||
// than the other tested carriers do.
|
|
||||||
supported = "path, gruu, outbound, sec-agree, 100rel, timer"
|
|
||||||
allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS"
|
|
||||||
}
|
|
||||||
if att310280 {
|
|
||||||
supported = "path,sec-agree,gruu"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
|
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
|
||||||
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
|
if override := strings.TrimSpace(registerOptions.UserAgent); override != "" &&
|
||||||
userAgent = "SimAdmin VoWiFi"
|
(userAgent == "" || userAgent == "vocat/1") {
|
||||||
|
userAgent = override
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := []string{
|
lines := []string{
|
||||||
"REGISTER " + requestURI + " SIP/2.0",
|
"REGISTER " + requestURI + " SIP/2.0",
|
||||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
|
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
|
||||||
@@ -972,28 +967,45 @@ func (session *Session) buildRegister(
|
|||||||
fmt.Sprintf("CSeq: %d REGISTER", cseq),
|
fmt.Sprintf("CSeq: %d REGISTER", cseq),
|
||||||
"Contact: " + contact,
|
"Contact: " + contact,
|
||||||
fmt.Sprintf("Expires: %d", expires),
|
fmt.Sprintf("Expires: %d", expires),
|
||||||
"Supported: " + supported,
|
|
||||||
"Allow: " + allow,
|
|
||||||
"User-Agent: " + userAgent,
|
|
||||||
}
|
}
|
||||||
if o2Germany {
|
if supported != "" {
|
||||||
|
lines = append(lines, "Supported: "+supported)
|
||||||
|
}
|
||||||
|
if allow != "" {
|
||||||
|
lines = append(lines, "Allow: "+allow)
|
||||||
|
}
|
||||||
|
lines = append(lines, "User-Agent: "+userAgent)
|
||||||
|
|
||||||
|
defaultPANI := "IEEE-802.11;i-wlan-node-id=000000000000;network-provided"
|
||||||
|
pani := defaultPANI
|
||||||
|
if registerOptions.PAccessNetworkInfo != nil {
|
||||||
|
pani = *registerOptions.PAccessNetworkInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
if registerOptions.PPreferredIdentity {
|
||||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||||
} else if att310280 {
|
|
||||||
lines = append(lines,
|
|
||||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
|
||||||
`P-Visited-Network-ID: "one.att.net"`,
|
|
||||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
|
||||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
|
||||||
"Accept-Contact: *;+g.3gpp.smsip",
|
|
||||||
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
lines = append(lines,
|
|
||||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
|
||||||
"Accept-Contact: *;+g.3gpp.smsip",
|
|
||||||
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
||||||
|
lines = append(lines, `P-Visited-Network-ID: "`+value+`"`)
|
||||||
|
}
|
||||||
|
if pani != "" {
|
||||||
|
lines = append(lines, "P-Access-Network-Info: "+pani)
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
||||||
|
lines = append(lines, "Cellular-Network-Info: "+value)
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptContactTags := []string{
|
||||||
|
"*;+g.3gpp.smsip",
|
||||||
|
`*;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
||||||
|
}
|
||||||
|
if registerOptions.AcceptContactTags != nil {
|
||||||
|
acceptContactTags = registerOptions.AcceptContactTags
|
||||||
|
}
|
||||||
|
for _, tag := range acceptContactTags {
|
||||||
|
lines = append(lines, "Accept-Contact: "+tag)
|
||||||
|
}
|
||||||
|
|
||||||
if session.securityOffered() {
|
if session.securityOffered() {
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
"Security-Client: "+session.securityClientValue(),
|
"Security-Client: "+session.securityClientValue(),
|
||||||
@@ -1020,6 +1032,33 @@ func (session *Session) buildRegister(
|
|||||||
return []byte(strings.Join(lines, "\r\n")), nil
|
return []byte(strings.Join(lines, "\r\n")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (session *Session) buildContact(contactAddress string, registerOptions vowifi.IMSRegisterOptions) string {
|
||||||
|
base := fmt.Sprintf("<sip:%s@%s;transport=%s>", session.identity.user, contactAddress, session.transport)
|
||||||
|
instanceID := session.instanceID
|
||||||
|
icsiRef := "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"
|
||||||
|
|
||||||
|
switch registerOptions.ContactFormat {
|
||||||
|
case vowifi.IMSContactFormatATT:
|
||||||
|
extra := ""
|
||||||
|
for _, tag := range registerOptions.ContactExtraTags {
|
||||||
|
extra += ";" + tag
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
|
||||||
|
base, extra, icsiRef, instanceID,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
extra := ""
|
||||||
|
for _, tag := range registerOptions.ContactExtraTags {
|
||||||
|
extra += ";" + tag
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`%s;+sip.instance="<%s>";+g.3gpp.smsip;audio;+g.3gpp.icsi-ref="%s"%s`,
|
||||||
|
base, instanceID, icsiRef, extra,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) {
|
func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1372,13 +1411,28 @@ func (session *Session) EnableSMS(ctx context.Context) (vowifi.SMSEvidence, erro
|
|||||||
return vowifi.SMSEvidence{}, vowifi.ErrIMSNotRegistered
|
return vowifi.SMSEvidence{}, vowifi.ErrIMSNotRegistered
|
||||||
case !session.expiresAt.IsZero() && !time.Now().Before(session.expiresAt):
|
case !session.expiresAt.IsZero() && !time.Now().Before(session.expiresAt):
|
||||||
return vowifi.SMSEvidence{}, ErrRegistrationExpired
|
return vowifi.SMSEvidence{}, ErrRegistrationExpired
|
||||||
case !session.smsContactConfirmed:
|
case !session.smsCapabilityReady():
|
||||||
return vowifi.SMSEvidence{Ready: false}, ErrSMSCapabilityNotConfirmed
|
return vowifi.SMSEvidence{Ready: false}, ErrSMSCapabilityNotConfirmed
|
||||||
default:
|
default:
|
||||||
return vowifi.SMSEvidence{Ready: true}, nil
|
return vowifi.SMSEvidence{Ready: true}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (session *Session) smsCapabilityReady() bool {
|
||||||
|
if session.smsContactConfirmed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||||
|
if profile.AllowSMSWithoutContactConfirmation {
|
||||||
|
session.provider.config.Logger.Warn("IMS SMS capability was not confirmed by registrar; proceeding because carrier profile permits it",
|
||||||
|
"device_id", session.request.DeviceID,
|
||||||
|
"carrier_profile", profile.ID,
|
||||||
|
"match_source", profile.MatchSource)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) Close(ctx context.Context) error {
|
func (session *Session) Close(ctx context.Context) error {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
|||||||
|
|
||||||
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
|
||||||
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "tcp" {
|
||||||
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
t.Fatalf("standard transport = %q, want tcp", got)
|
||||||
}
|
}
|
||||||
if got := transportForIdentity(Config{
|
if got := transportForIdentity(Config{
|
||||||
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
Transport: "udp", TransportByPLMN: map[string]string{"99999": "tcp"},
|
||||||
}, identity); got != "tcp" {
|
}, identity); got != "tcp" {
|
||||||
t.Fatalf("explicit configuration did not override profile: %q", got)
|
t.Fatalf("explicit configuration did not override: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,138 +497,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
|
||||||
client, server := net.Pipe()
|
|
||||||
defer client.Close()
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
identity := vowifi.SIMIdentity{
|
|
||||||
IMSI: "262030123456789",
|
|
||||||
HomeMCC: "262",
|
|
||||||
HomeMNC: "03",
|
|
||||||
}
|
|
||||||
identities, err := deriveIdentities(identity, Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deriveIdentities() error = %v", err)
|
|
||||||
}
|
|
||||||
session := &Session{
|
|
||||||
provider: &Provider{config: Config{
|
|
||||||
SecurityMode: SecurityRequired,
|
|
||||||
UserAgent: "vocat-test",
|
|
||||||
}},
|
|
||||||
request: vowifi.IMSRequest{Identity: identity},
|
|
||||||
identity: identities,
|
|
||||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
|
||||||
transport: "tcp",
|
|
||||||
conn: client,
|
|
||||||
callID: "o2-test",
|
|
||||||
fromTag: "tag",
|
|
||||||
instanceID: "urn:uuid:test",
|
|
||||||
securityProposal: securityProposal{
|
|
||||||
spiClient: 101,
|
|
||||||
spiServer: 102,
|
|
||||||
portClient: 5062,
|
|
||||||
portServer: 5063,
|
|
||||||
encryption: "null",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
packet, err := session.buildRegister(1, 3600, "", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildRegister() error = %v", err)
|
|
||||||
}
|
|
||||||
_, headers, err := parseTestRequest(packet)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseTestRequest() error = %v", err)
|
|
||||||
}
|
|
||||||
if got, want := headers["security-client"], "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000000101;spi-s=0000000102;port-c=5062;port-s=5063"; got != want {
|
|
||||||
t.Fatalf("Security-Client = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
if headers["proxy-require"] != "sec-agree" || !strings.Contains(headers["authorization"], "integrity-protected=no") {
|
|
||||||
t.Fatalf("initial O2 headers omitted standardized sec-agree/IMS-AKA fields: %#v", headers)
|
|
||||||
}
|
|
||||||
if got, want := headers["p-preferred-identity"], "<"+identities.public+">"; got != want {
|
|
||||||
t.Fatalf("P-Preferred-Identity = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
for name, token := range map[string]string{
|
|
||||||
"supported": "sec-agree",
|
|
||||||
"allow": "MESSAGE",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(headers[name], token) {
|
|
||||||
t.Fatalf("%s = %q, want token %q", name, headers[name], token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
|
|
||||||
identities, err := deriveIdentities(vowifi.SIMIdentity{
|
|
||||||
IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280",
|
|
||||||
}, Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deriveIdentities() error = %v", err)
|
|
||||||
}
|
|
||||||
if identities.domain != "one.att.net" ||
|
|
||||||
identities.private != "[email protected]" ||
|
|
||||||
identities.public != "sip:[email protected]" {
|
|
||||||
t.Fatalf("AT&T identities = %#v", identities)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
|
|
||||||
client, server := net.Pipe()
|
|
||||||
defer client.Close()
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
identity := vowifi.SIMIdentity{
|
|
||||||
IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280",
|
|
||||||
}
|
|
||||||
identities, err := deriveIdentities(identity, Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
session := &Session{
|
|
||||||
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
|
|
||||||
request: vowifi.IMSRequest{Identity: identity},
|
|
||||||
identity: identities,
|
|
||||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
|
||||||
transport: "tcp",
|
|
||||||
conn: client,
|
|
||||||
callID: "att-test",
|
|
||||||
fromTag: "tag",
|
|
||||||
instanceID: "urn:uuid:test",
|
|
||||||
securityProposal: securityProposal{
|
|
||||||
spiClient: 1546543, spiServer: 1546542,
|
|
||||||
portClient: 32773, portServer: 6000,
|
|
||||||
integrityAlgorithms: []string{"hmac-sha-1-96"},
|
|
||||||
encryptionAlgorithmsList: []string{"aes-cbc"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
packet, err := session.buildRegister(1, 3600, "", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildRegister() error = %v", err)
|
|
||||||
}
|
|
||||||
request := string(packet)
|
|
||||||
for _, want := range []string{
|
|
||||||
"REGISTER sip:one.att.net SIP/2.0",
|
|
||||||
"Expires: 18400",
|
|
||||||
"Supported: path,sec-agree,gruu",
|
|
||||||
"User-Agent: SimAdmin VoWiFi",
|
|
||||||
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
|
|
||||||
"P-Preferred-Identity: <sip:[email protected]>",
|
|
||||||
`P-Visited-Network-ID: "one.att.net"`,
|
|
||||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
|
||||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
|
||||||
"Accept-Contact: *;+g.3gpp.smsip",
|
|
||||||
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
|
|
||||||
`username="[email protected]"`,
|
|
||||||
`uri="sip:one.att.net"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(request, want) {
|
|
||||||
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||||
var callID string
|
var callID string
|
||||||
for step := 0; step < 3; step++ {
|
for step := 0; step < 3; step++ {
|
||||||
|
|||||||
@@ -38,28 +38,18 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) {
|
func TestSecurityProposalDefaultUsesAESCBC(t *testing.T) {
|
||||||
identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"}
|
identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
|
||||||
if got := securityEncryptionForIdentity(identity); got != "null" {
|
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
||||||
t.Fatalf("O2 security encryption = %q, want null", got)
|
t.Fatalf("standard security encryption = %q, want aes-cbc", got)
|
||||||
}
|
}
|
||||||
proposal := securityProposal{
|
proposal := securityProposal{
|
||||||
spiClient: 1001, spiServer: 1002,
|
spiClient: 1001, spiServer: 1002,
|
||||||
portClient: 40666, portServer: 55610,
|
portClient: 40666, portServer: 55610,
|
||||||
encryption: securityEncryptionForIdentity(identity),
|
encryption: securityEncryptionForIdentity(identity),
|
||||||
}
|
}
|
||||||
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
||||||
t.Fatalf("O2 Security-Client = %q, want %q", got, want)
|
t.Fatalf("Security-Client = %q, want %q", got, want)
|
||||||
}
|
|
||||||
selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
|
||||||
"ealg=null;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
|
||||||
if _, err := parseSecurityAgreement([]string{selected}, proposal); err != nil {
|
|
||||||
t.Fatalf("O2 null Security-Server rejected: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
identity.HomeMNC = "02"
|
|
||||||
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
|
||||||
t.Fatalf("non-O2 security encryption = %q, want aes-cbc", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
smsContentType = "application/vnd.3gpp.sms"
|
smsContentType = "application/vnd.3gpp.sms"
|
||||||
|
ussiContentType = "application/vnd.3gpp.ussd"
|
||||||
sipMessageRetransmitT1 = 500 * time.Millisecond
|
sipMessageRetransmitT1 = 500 * time.Millisecond
|
||||||
sipMessageRetransmitMax = 4 * time.Second
|
sipMessageRetransmitMax = 4 * time.Second
|
||||||
)
|
)
|
||||||
@@ -52,6 +54,7 @@ type ReceivedSMS struct {
|
|||||||
CallID string
|
CallID string
|
||||||
RawRPDU string
|
RawRPDU string
|
||||||
RawTPDU string
|
RawTPDU string
|
||||||
|
DecodeError string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
|
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
|
||||||
@@ -69,6 +72,24 @@ type ReceivedSMSStatus struct {
|
|||||||
CallID string
|
CallID string
|
||||||
RawRPDU string
|
RawRPDU string
|
||||||
RawTPDU string
|
RawTPDU string
|
||||||
|
DecodeError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceivedUSSD is a decoded network-originated USSD message delivered over IMS
|
||||||
|
// (3GPP TS 24.390). Status carries the network's USSD operation code semantics
|
||||||
|
// ("final", "awaiting_input", "terminated") when present in the body.
|
||||||
|
type ReceivedUSSD struct {
|
||||||
|
MessageID string
|
||||||
|
DeviceID string
|
||||||
|
IMSI string
|
||||||
|
From string
|
||||||
|
Text string
|
||||||
|
DCS *int
|
||||||
|
Status string
|
||||||
|
Continueable bool
|
||||||
|
Timestamp time.Time
|
||||||
|
CallID string
|
||||||
|
RawBody string
|
||||||
}
|
}
|
||||||
|
|
||||||
type sipTransactionKey struct {
|
type sipTransactionKey struct {
|
||||||
@@ -343,10 +364,16 @@ func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := 200
|
status := 200
|
||||||
|
ussiMessage := false
|
||||||
switch request.Method {
|
switch request.Method {
|
||||||
case "OPTIONS":
|
case "OPTIONS":
|
||||||
case "MESSAGE":
|
case "MESSAGE":
|
||||||
if !supportsSMSContentType(request.value("Content-Type")) {
|
switch {
|
||||||
|
case supportsSMSContentType(request.value("Content-Type")):
|
||||||
|
// SMS body handled below.
|
||||||
|
case supportsUSSIContentType(request.value("Content-Type")):
|
||||||
|
ussiMessage = true
|
||||||
|
default:
|
||||||
status = 415
|
status = 415
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -362,11 +389,17 @@ func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byt
|
|||||||
}
|
}
|
||||||
if status != 200 || request.Method != "MESSAGE" {
|
if status != 200 || request.Method != "MESSAGE" {
|
||||||
if request.Method == "MESSAGE" {
|
if request.Method == "MESSAGE" {
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS MESSAGE rejected", request,
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound MESSAGE rejected", request,
|
||||||
"stage", "content_type", "sip_status", status)
|
"stage", "content_type", "sip_status", status)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ussiMessage {
|
||||||
|
session.logInboundSMS(slog.LevelInfo, "IMS inbound USSD MESSAGE received", request,
|
||||||
|
"stage", "sip_accepted")
|
||||||
|
go session.processUSSIMessage(request)
|
||||||
|
return
|
||||||
|
}
|
||||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS MESSAGE received", request,
|
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS MESSAGE received", request,
|
||||||
"stage", "sip_accepted")
|
"stage", "sip_accepted")
|
||||||
go session.processSMSMessage(request)
|
go session.processSMSMessage(request)
|
||||||
@@ -384,6 +417,14 @@ func supportsSMSContentType(value string) bool {
|
|||||||
strings.TrimSpace(parameters["boundary"]) != ""
|
strings.TrimSpace(parameters["boundary"]) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func supportsUSSIContentType(value string) bool {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(mediaType, ussiContentType)
|
||||||
|
}
|
||||||
|
|
||||||
func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, error) {
|
func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, error) {
|
||||||
reason := map[int]string{200: "OK", 405: "Method Not Allowed", 415: "Unsupported Media Type", 488: "Not Acceptable Here"}[status]
|
reason := map[int]string{200: "OK", 405: "Method Not Allowed", 415: "Unsupported Media Type", 488: "Not Acceptable Here"}[status]
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
@@ -414,7 +455,7 @@ func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, erro
|
|||||||
lines = append(lines, "Allow: REGISTER, MESSAGE, OPTIONS")
|
lines = append(lines, "Allow: REGISTER, MESSAGE, OPTIONS")
|
||||||
}
|
}
|
||||||
if status == 415 {
|
if status == 415 {
|
||||||
lines = append(lines, "Accept: "+smsContentType)
|
lines = append(lines, "Accept: "+smsContentType+", "+ussiContentType)
|
||||||
}
|
}
|
||||||
lines = append(lines, "Content-Length: 0", "", "")
|
lines = append(lines, "Content-Length: 0", "", "")
|
||||||
return []byte(strings.Join(lines, "\r\n")), nil
|
return []byte(strings.Join(lines, "\r\n")), nil
|
||||||
@@ -446,29 +487,28 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
|||||||
"rp_message_type", int(rpdu.messageType), "rp_reference", int(rpdu.reference))
|
"rp_message_type", int(rpdu.messageType), "rp_reference", int(rpdu.reference))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message, err := device.DecodeSMSDeliverTPDU(rpdu.tpdu)
|
|
||||||
if err != nil {
|
message, decodeErr := device.DecodeSMSDeliverTPDU(rpdu.tpdu)
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
|
|
||||||
"stage", "tpdu", "payload_source", payloadSource,
|
|
||||||
"rp_reference", int(rpdu.reference), "tpdu_bytes", len(rpdu.tpdu), "error", err)
|
|
||||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedAt := time.Now().UTC()
|
receivedAt := time.Now().UTC()
|
||||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||||
if message.Direction == device.SMSDirectionStatusReport {
|
carrierProfile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||||
if message.MessageReference == nil || message.StatusCode == nil {
|
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
|
if decodeErr != nil {
|
||||||
"stage", "tpdu", "rp_reference", int(rpdu.reference))
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed; persisting raw payload", request,
|
||||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
"stage", "tpdu", "payload_source", payloadSource,
|
||||||
return
|
"rp_reference", int(rpdu.reference), "tpdu_bytes", len(rpdu.tpdu),
|
||||||
}
|
"carrier_profile", carrierProfile.ID,
|
||||||
|
"direction", message.Direction, "error", decodeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case message.Direction == device.SMSDirectionStatusReport:
|
||||||
status := ReceivedSMSStatus{
|
status := ReceivedSMSStatus{
|
||||||
DeviceID: session.request.DeviceID,
|
DeviceID: session.request.DeviceID,
|
||||||
IMSI: session.request.Identity.IMSI,
|
IMSI: session.request.Identity.IMSI,
|
||||||
To: message.To,
|
To: message.To,
|
||||||
MessageReference: *message.MessageReference,
|
MessageReference: intPtrValue(message.MessageReference),
|
||||||
StatusCode: *message.StatusCode,
|
StatusCode: intPtrValue(message.StatusCode),
|
||||||
DeliveryStatus: message.DeliveryStatus,
|
DeliveryStatus: message.DeliveryStatus,
|
||||||
ServiceCenterTimestamp: message.ServiceCenterTimestamp,
|
ServiceCenterTimestamp: message.ServiceCenterTimestamp,
|
||||||
DischargeTimestamp: message.DischargeTimestamp,
|
DischargeTimestamp: message.DischargeTimestamp,
|
||||||
@@ -477,12 +517,15 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
|||||||
CallID: callID,
|
CallID: callID,
|
||||||
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
||||||
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
||||||
|
DecodeError: errorString(decodeErr),
|
||||||
}
|
}
|
||||||
if session.provider.config.OnSMSStatus != nil {
|
if (message.MessageReference == nil || message.StatusCode == nil) && decodeErr == nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
|
||||||
err = session.provider.config.OnSMSStatus(ctx, status)
|
"stage", "tpdu", "rp_reference", int(rpdu.reference))
|
||||||
cancel()
|
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
err := session.invokeSMSStatusCallback(status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status persistence failed", request,
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status persistence failed", request,
|
||||||
"stage", "status_callback", "rp_reference", int(rpdu.reference), "error", err)
|
"stage", "status_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||||
@@ -491,55 +534,84 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
|||||||
}
|
}
|
||||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS status report processed", request,
|
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS status report processed", request,
|
||||||
"stage", "status_callback", "rp_reference", int(rpdu.reference),
|
"stage", "status_callback", "rp_reference", int(rpdu.reference),
|
||||||
"status_code", *message.StatusCode)
|
"status_code", status.StatusCode)
|
||||||
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
||||||
return
|
|
||||||
}
|
case message.Direction == device.SMSDirectionReceived || decodeErr != nil:
|
||||||
if message.Direction != device.SMSDirectionReceived {
|
var serviceCenterTimestamp *time.Time
|
||||||
|
if message.ServiceCenterTimestamp != nil {
|
||||||
|
value := message.ServiceCenterTimestamp.UTC()
|
||||||
|
serviceCenterTimestamp = &value
|
||||||
|
}
|
||||||
|
received := ReceivedSMS{
|
||||||
|
// A retransmission inside the same SIP transaction is idempotent, but a
|
||||||
|
// fresh Call-ID/RP reference is a distinct network delivery and must stay
|
||||||
|
// visible even when its TPDU and text happen to be identical.
|
||||||
|
MessageID: fmt.Sprintf("ims:%s:%d", callID, rpdu.reference),
|
||||||
|
DeviceID: session.request.DeviceID,
|
||||||
|
IMSI: session.request.Identity.IMSI,
|
||||||
|
From: message.From,
|
||||||
|
Text: message.Text,
|
||||||
|
Timestamp: receivedAt,
|
||||||
|
ServiceCenterTimestamp: serviceCenterTimestamp,
|
||||||
|
Encoding: message.Encoding,
|
||||||
|
Concat: message.Concat,
|
||||||
|
RPReference: int(rpdu.reference),
|
||||||
|
CallID: callID,
|
||||||
|
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
||||||
|
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
||||||
|
DecodeError: errorString(decodeErr),
|
||||||
|
}
|
||||||
|
err := session.invokeSMSCallback(received)
|
||||||
|
if err != nil {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
|
||||||
|
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||||
|
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
|
||||||
|
"stage", "sms_callback", "payload_source", payloadSource,
|
||||||
|
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
|
||||||
|
"concatenated", message.Concat != nil, "decode_error", decodeErr != nil)
|
||||||
|
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
||||||
|
|
||||||
|
default:
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
|
||||||
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
|
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
|
||||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var serviceCenterTimestamp *time.Time
|
}
|
||||||
if message.ServiceCenterTimestamp != nil {
|
|
||||||
value := message.ServiceCenterTimestamp.UTC()
|
func (session *Session) invokeSMSCallback(received ReceivedSMS) error {
|
||||||
serviceCenterTimestamp = &value
|
if session.provider.config.OnSMS == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
received := ReceivedSMS{
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
// A retransmission inside the same SIP transaction is idempotent, but a
|
defer cancel()
|
||||||
// fresh Call-ID/RP reference is a distinct network delivery and must stay
|
return session.provider.config.OnSMS(ctx, received)
|
||||||
// visible even when its TPDU and text happen to be identical.
|
}
|
||||||
MessageID: fmt.Sprintf("ims:%s:%d", callID, rpdu.reference),
|
|
||||||
DeviceID: session.request.DeviceID,
|
func (session *Session) invokeSMSStatusCallback(status ReceivedSMSStatus) error {
|
||||||
IMSI: session.request.Identity.IMSI,
|
if session.provider.config.OnSMSStatus == nil {
|
||||||
From: message.From,
|
return nil
|
||||||
Text: message.Text,
|
|
||||||
Timestamp: receivedAt,
|
|
||||||
ServiceCenterTimestamp: serviceCenterTimestamp,
|
|
||||||
Encoding: message.Encoding,
|
|
||||||
Concat: message.Concat,
|
|
||||||
RPReference: int(rpdu.reference),
|
|
||||||
CallID: callID,
|
|
||||||
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
|
||||||
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
|
||||||
}
|
}
|
||||||
if session.provider.config.OnSMS != nil {
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
defer cancel()
|
||||||
err = session.provider.config.OnSMS(ctx, received)
|
return session.provider.config.OnSMSStatus(ctx, status)
|
||||||
cancel()
|
}
|
||||||
|
|
||||||
|
func intPtrValue(value *int) int {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
if err != nil {
|
return *value
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
|
}
|
||||||
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
|
|
||||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
|
func errorString(err error) string {
|
||||||
return
|
if err == nil {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
|
return err.Error()
|
||||||
"stage", "sms_callback", "payload_source", payloadSource,
|
|
||||||
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
|
|
||||||
"concatenated", message.Concat != nil)
|
|
||||||
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSMSPayload(request *sipRequest) ([]byte, string, error) {
|
func extractSMSPayload(request *sipRequest) ([]byte, string, error) {
|
||||||
@@ -607,6 +679,235 @@ func decodeSMSTransfer(body []byte, encoding string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeUSSDBody encodes a USSD string for a TS 24.390 application/vnd.3gpp.ussd
|
||||||
|
// body. To avoid carrier-specific GSM-7 packing conventions the body is always
|
||||||
|
// UTF-16 (big-endian) with DCS 0x48, which every USSI-capable P-CSCF accepts.
|
||||||
|
func encodeUSSDBody(text string) ([]byte, *int, error) {
|
||||||
|
dcs := 0x48
|
||||||
|
if text == "" {
|
||||||
|
return nil, &dcs, nil
|
||||||
|
}
|
||||||
|
encoded := utf16.Encode([]rune(text))
|
||||||
|
body := make([]byte, 0, len(encoded)*2)
|
||||||
|
for _, unit := range encoded {
|
||||||
|
body = append(body, byte(unit>>8), byte(unit))
|
||||||
|
}
|
||||||
|
return body, &dcs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeUSSDBody reverses encodeUSSDBody using the data coding scheme carried
|
||||||
|
// alongside the USSD string. DCS 0x00/0x0F => GSM 7-bit default alphabet
|
||||||
|
// (unpacked one code per byte, as some carriers send); 0x48 => UCS2/UTF-16.
|
||||||
|
// Any other DCS is treated as raw bytes.
|
||||||
|
func decodeUSSDBody(body []byte, dcs int) string {
|
||||||
|
switch dcs {
|
||||||
|
case 0x00, 0x0F:
|
||||||
|
if decoded, ok := device.DecodeGSM7Septets(string(body)); ok {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dcs == 0x48 && len(body) > 0 && len(body)%2 == 0 {
|
||||||
|
units := make([]uint16, 0, len(body)/2)
|
||||||
|
for index := 0; index < len(body); index += 2 {
|
||||||
|
units = append(units, uint16(body[index])<<8|uint16(body[index+1]))
|
||||||
|
}
|
||||||
|
return string(utf16.Decode(units))
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processUSSIMessage decodes a network-originated USSD MESSAGE and hands it to
|
||||||
|
// the OnUSSD callback. Unlike SMS there is no RP-ACK transport, so the 200 OK
|
||||||
|
// has already been sent by handleSIPRequest and this routine only logs callback
|
||||||
|
// failures.
|
||||||
|
func (session *Session) processUSSIMessage(request *sipRequest) {
|
||||||
|
body, dcs, text, err := extractUSSDBody(request)
|
||||||
|
if err != nil {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound USSD decode failed", request,
|
||||||
|
"stage", "mime", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||||
|
received := ReceivedUSSD{
|
||||||
|
MessageID: fmt.Sprintf("ims-ussd:%s", callID),
|
||||||
|
DeviceID: session.request.DeviceID,
|
||||||
|
IMSI: session.request.Identity.IMSI,
|
||||||
|
From: firstURI(request.value("P-Asserted-Identity")),
|
||||||
|
Text: text,
|
||||||
|
DCS: dcs,
|
||||||
|
Status: "final",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
CallID: callID,
|
||||||
|
RawBody: strings.ToUpper(hex.EncodeToString(body)),
|
||||||
|
}
|
||||||
|
if session.provider.config.OnUSSD != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
err = session.provider.config.OnUSSD(ctx, received)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound USSD callback failed", request,
|
||||||
|
"stage", "ussd_callback", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.logInboundSMS(slog.LevelInfo, "IMS inbound USSD processed", request,
|
||||||
|
"stage", "ussd_callback", "dcs", dcsPointerToInt(dcs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dcsPointerToInt(value *int) int {
|
||||||
|
if value == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUSSDBody decodes a TS 24.390 USSD body. The body is a sequence of
|
||||||
|
// information elements; the common form is an optional language/network
|
||||||
|
// indicator followed by the USSD string with its DCS. We scan for a component
|
||||||
|
// whose length leaves a trailing DCS+string pair, returning the string, its
|
||||||
|
// DCS, and the raw bytes.
|
||||||
|
func extractUSSDBody(request *sipRequest) (raw []byte, dcs *int, text string, err error) {
|
||||||
|
if request == nil {
|
||||||
|
return nil, nil, "", errors.New("ims: USSD MESSAGE is nil")
|
||||||
|
}
|
||||||
|
body, decodeErr := decodeSMSTransfer(request.Body, request.value("Content-Transfer-Encoding"))
|
||||||
|
if decodeErr != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("ims: decode USSD body: %w", decodeErr)
|
||||||
|
}
|
||||||
|
raw, dcs, text = extractUSSDString(body)
|
||||||
|
return raw, dcs, text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUSSDString walks the TS 24.390 information elements looking for the
|
||||||
|
// USSD string component: [length][DCS][octets...]. A leading 0xAB language
|
||||||
|
// indicator pair is skipped. If no structured component is found, the whole
|
||||||
|
// body is treated as a DCS 0x0F string.
|
||||||
|
func extractUSSDString(body []byte) (raw []byte, dcs *int, text string) {
|
||||||
|
for offset := 0; offset+1 < len(body); {
|
||||||
|
if body[offset] == 0xAB {
|
||||||
|
// Language/network indicator: [0xAB][length of language].
|
||||||
|
if offset+1 >= len(body) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
skip := int(body[offset+1])
|
||||||
|
offset += 2 + skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// USSD string component: [length][DCS][octets...], length counts
|
||||||
|
// everything after the length byte (DCS + string octets).
|
||||||
|
length := int(body[offset])
|
||||||
|
if length < 1 || offset+1+length > len(body) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dcsValue := int(body[offset+1])
|
||||||
|
stringBytes := body[offset+2 : offset+1+length]
|
||||||
|
dcs = &dcsValue
|
||||||
|
return body, dcs, decodeUSSDBody(stringBytes, dcsValue)
|
||||||
|
}
|
||||||
|
zero := 0x0F
|
||||||
|
return body, &zero, decodeUSSDBody(body, zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendUSSI submits a USSD dialog turn over IMS. The first turn carries the
|
||||||
|
// service code in request.Code; a follow-up turn on an open dialog carries the
|
||||||
|
// menu reply in request.Input. USSI does not require the +g.3gpp.smsip contact
|
||||||
|
// to be confirmed — only IMS registration.
|
||||||
|
func (session *Session) SendUSSI(ctx context.Context, request vowifi.USSISubmitRequest) (vowifi.USSISubmitResult, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
session.smsMu.Lock()
|
||||||
|
defer session.smsMu.Unlock()
|
||||||
|
|
||||||
|
session.mu.Lock()
|
||||||
|
if session.closed || !session.evidence.Registered {
|
||||||
|
session.mu.Unlock()
|
||||||
|
return vowifi.USSISubmitResult{}, vowifi.ErrUSSINotReady
|
||||||
|
}
|
||||||
|
target := session.ussiTarget()
|
||||||
|
session.mu.Unlock()
|
||||||
|
|
||||||
|
payload := strings.TrimSpace(firstNonEmpty(request.Input, request.Code))
|
||||||
|
if payload == "" {
|
||||||
|
return vowifi.USSISubmitResult{}, errors.New("ims: USSI payload is empty")
|
||||||
|
}
|
||||||
|
body, dcs, err := encodeUSSDBody(payload)
|
||||||
|
if err != nil {
|
||||||
|
return vowifi.USSISubmitResult{}, err
|
||||||
|
}
|
||||||
|
// TS 24.390 §5.2.1: [language indicator]? [length][DCS][USSD string].
|
||||||
|
// The length byte counts the DCS plus the string octets that follow it.
|
||||||
|
stringOctets := body
|
||||||
|
length := len(stringOctets) + 1
|
||||||
|
if length > 255 {
|
||||||
|
return vowifi.USSISubmitResult{}, errors.New("ims: USSD string exceeds 254 octets")
|
||||||
|
}
|
||||||
|
message := make([]byte, 0, 2+len(stringOctets))
|
||||||
|
message = append(message, byte(length), byte(*dcs))
|
||||||
|
message = append(message, stringOctets...)
|
||||||
|
response, sendErr := session.sendSIPMessageWith(ctx, target, message, "", ussiContentType, "ussd")
|
||||||
|
result := vowifi.USSISubmitResult{
|
||||||
|
SubmissionStatus: "pending",
|
||||||
|
}
|
||||||
|
if response != nil {
|
||||||
|
result.SIPCode = response.StatusCode
|
||||||
|
}
|
||||||
|
if sendErr != nil {
|
||||||
|
result.SubmissionStatus = "failed"
|
||||||
|
result.Raw = strings.ToUpper(hex.EncodeToString(message))
|
||||||
|
return result, sendErr
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
result.SubmissionStatus = "rejected_by_ims"
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Raw = strings.ToUpper(hex.EncodeToString(message))
|
||||||
|
return result, fmt.Errorf("ims: USSI rejected with SIP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
// A 2xx response may carry the network's reply in the same MESSAGE body.
|
||||||
|
text, replyDCS := session.parseUSSIReply(response)
|
||||||
|
result.Text = text
|
||||||
|
result.DCS = replyDCS
|
||||||
|
result.Status = "final"
|
||||||
|
result.Continueable = false
|
||||||
|
result.Raw = strings.ToUpper(hex.EncodeToString(message))
|
||||||
|
if result.Status == "" {
|
||||||
|
result.Status = "final"
|
||||||
|
}
|
||||||
|
result.SubmissionStatus = "accepted_by_ims"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUSSIReply decodes the USSD body of a 2xx response when the network
|
||||||
|
// returned the dialog reply inline. A missing body is a final empty reply.
|
||||||
|
func (session *Session) parseUSSIReply(response *sipResponse) (string, *int) {
|
||||||
|
if response == nil || len(response.Body) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !supportsUSSIContentType(response.value("Content-Type")) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
_, dcs, text := extractUSSDString(response.Body)
|
||||||
|
return text, dcs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) ussiTarget() string {
|
||||||
|
if number, _, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok {
|
||||||
|
if normalized := normalizeE164(number); normalized != "" {
|
||||||
|
return "tel:" + normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session.identity.public
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) logInboundSMS(level slog.Level, message string, request *sipRequest, attributes ...any) {
|
func (session *Session) logInboundSMS(level slog.Level, message string, request *sipRequest, attributes ...any) {
|
||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
|
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||||
@@ -664,7 +965,7 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
|||||||
defer session.smsMu.Unlock()
|
defer session.smsMu.Unlock()
|
||||||
|
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
if session.closed || !session.evidence.Registered || !session.smsContactConfirmed {
|
if session.closed || !session.evidence.Registered || !session.smsCapabilityReady() {
|
||||||
session.mu.Unlock()
|
session.mu.Unlock()
|
||||||
return vowifi.SMSSubmitResult{}, vowifi.ErrSMSNotReady
|
return vowifi.SMSSubmitResult{}, vowifi.ErrSMSNotReady
|
||||||
}
|
}
|
||||||
@@ -762,6 +1063,9 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||||
|
if identitySMSC := strings.TrimSpace(identity.SMSC); identitySMSC != "" {
|
||||||
|
return identitySMSC
|
||||||
|
}
|
||||||
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
||||||
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||||
return configured
|
return configured
|
||||||
@@ -810,6 +1114,20 @@ func (session *Session) sendSIPMessage(
|
|||||||
target string,
|
target string,
|
||||||
body []byte,
|
body []byte,
|
||||||
inReplyTo string,
|
inReplyTo string,
|
||||||
|
) (*sipResponse, error) {
|
||||||
|
return session.sendSIPMessageWith(ctx, target, body, inReplyTo, smsContentType, "smsip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSIPMessageWith is the parameterized MESSAGE transaction used by both SMS
|
||||||
|
// and USSI. acceptContactTag is the 3gpp feature tag (e.g. "smsip" or "ussd")
|
||||||
|
// advertised via Accept-Contact; pass an empty string to omit the header.
|
||||||
|
func (session *Session) sendSIPMessageWith(
|
||||||
|
ctx context.Context,
|
||||||
|
target string,
|
||||||
|
body []byte,
|
||||||
|
inReplyTo string,
|
||||||
|
contentType string,
|
||||||
|
acceptContactTag string,
|
||||||
) (*sipResponse, error) {
|
) (*sipResponse, error) {
|
||||||
callToken, err := randomHex(18)
|
callToken, err := randomHex(18)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -849,7 +1167,11 @@ func (session *Session) sendSIPMessage(
|
|||||||
"Call-ID: "+callID,
|
"Call-ID: "+callID,
|
||||||
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
|
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
|
||||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||||
"Accept-Contact: *;+g.3gpp.smsip",
|
)
|
||||||
|
if acceptContactTag != "" {
|
||||||
|
lines = append(lines, "Accept-Contact: *;+g.3gpp."+acceptContactTag)
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
"Request-Disposition: no-fork",
|
"Request-Disposition: no-fork",
|
||||||
"Allow: MESSAGE",
|
"Allow: MESSAGE",
|
||||||
)
|
)
|
||||||
@@ -857,7 +1179,7 @@ func (session *Session) sendSIPMessage(
|
|||||||
lines = append(lines, "In-Reply-To: "+inReplyTo)
|
lines = append(lines, "In-Reply-To: "+inReplyTo)
|
||||||
}
|
}
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
"Content-Type: "+smsContentType,
|
"Content-Type: "+contentType,
|
||||||
"Content-Transfer-Encoding: binary",
|
"Content-Transfer-Encoding: binary",
|
||||||
"Content-Length: "+strconv.Itoa(len(body)),
|
"Content-Length: "+strconv.Itoa(len(body)),
|
||||||
"", "",
|
"", "",
|
||||||
@@ -1025,3 +1347,4 @@ func (session *Session) closeInboundConnections() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ vowifi.SMSSender = (*Session)(nil)
|
var _ vowifi.SMSSender = (*Session)(nil)
|
||||||
|
var _ vowifi.USSISender = (*Session)(nil)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net"
|
"net"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
@@ -158,6 +159,57 @@ func TestSupportsSMSContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSupportsUSSIContentType(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
value string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{ussiContentType, true},
|
||||||
|
{"Application/Vnd.3gpp.Ussd; charset=binary", true},
|
||||||
|
{smsContentType, false},
|
||||||
|
{"text/plain", false},
|
||||||
|
} {
|
||||||
|
if got := supportsUSSIContentType(test.value); got != test.want {
|
||||||
|
t.Errorf("supportsUSSIContentType(%q) = %v, want %v", test.value, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeDecodeUSSDBody(t *testing.T) {
|
||||||
|
for _, text := range []string{"*100#", "Main menu 中文"} {
|
||||||
|
body, dcs, err := encodeUSSDBody(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encodeUSSDBody(%q) error = %v", text, err)
|
||||||
|
}
|
||||||
|
if dcs == nil || *dcs != 0x48 {
|
||||||
|
t.Fatalf("encodeUSSDBody(%q) dcs = %v, want 0x48", text, dcs)
|
||||||
|
}
|
||||||
|
decoded := decodeUSSDBody(body, *dcs)
|
||||||
|
if decoded != text {
|
||||||
|
t.Fatalf("decodeUSSDBody(%q) = %q, want %q", text, decoded, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUSSDString(t *testing.T) {
|
||||||
|
text := "Main menu"
|
||||||
|
encoded, dcs, err := encodeUSSDBody(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := append([]byte{byte(len(encoded) + 1), byte(*dcs)}, encoded...)
|
||||||
|
raw, gotDCS, gotText := extractUSSDString(body)
|
||||||
|
if gotText != text || gotDCS == nil || *gotDCS != *dcs || !bytes.Equal(raw, body) {
|
||||||
|
t.Fatalf("extractUSSDString(%x) = (%q, %v, %q)", body, raw, gotDCS, gotText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain raw body without a length/DCS prefix falls back to DCS 0x0F.
|
||||||
|
raw, gotDCS, gotText = extractUSSDString([]byte("fallback"))
|
||||||
|
if gotDCS == nil || *gotDCS != 0x0F || gotText != "fallback" || !bytes.Equal(raw, []byte("fallback")) {
|
||||||
|
t.Fatalf("extractUSSDString fallback = (%q, %v, %q)", raw, gotDCS, gotText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
||||||
config := Config{SMSCenterByPLMN: map[string]string{
|
config := Config{SMSCenterByPLMN: map[string]string{
|
||||||
"23410": "+447802000332",
|
"23410": "+447802000332",
|
||||||
@@ -181,18 +233,19 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||||
for _, test := range []struct {
|
// Explicit SIM SMSC always takes precedence
|
||||||
mnc string
|
explicit := smsCenterForIdentity(Config{}, vowifi.SIMIdentity{
|
||||||
want string
|
HomeMCC: "234", HomeMNC: "15", SMSC: "+447785016005",
|
||||||
}{
|
})
|
||||||
{mnc: "10", want: "+447802000332"},
|
if explicit != "+447785016005" {
|
||||||
{mnc: "15", want: "+447785016005"},
|
t.Fatalf("explicit SMSC = %q, want +447785016005", explicit)
|
||||||
{mnc: "30", want: ""},
|
}
|
||||||
} {
|
|
||||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
// Profile fallback when identity has no SMSC
|
||||||
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
|
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||||
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
}
|
if got := smsCenterForIdentity(Config{}, identity); got != profile.SMSCenter {
|
||||||
|
t.Errorf("smsCenterForIdentity = %q, want %q", got, profile.SMSCenter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,3 +592,369 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
|||||||
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
|
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionReceivesUSSIOverIMS(t *testing.T) {
|
||||||
|
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
_ = listener.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
|
||||||
|
received := make(chan ReceivedUSSD, 1)
|
||||||
|
serverDone := make(chan error, 1)
|
||||||
|
readyForClose := make(chan struct{})
|
||||||
|
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||||
|
go func() { serverDone <- serveInboundUSSI(listener, nonce, readyForClose) }()
|
||||||
|
provider, err := NewProvider(
|
||||||
|
smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}},
|
||||||
|
Config{
|
||||||
|
PCSCF: listener.LocalAddr().String(), LocalAddress: "127.0.0.1",
|
||||||
|
Transport: "udp", TransactionTimeout: 3 * time.Second, SecurityMode: SecurityDisabled,
|
||||||
|
OnUSSD: func(_ context.Context, message ReceivedUSSD) error {
|
||||||
|
received <- message
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session, err := provider.Start(context.Background(), vowifi.IMSRequest{
|
||||||
|
DeviceID: "ec20",
|
||||||
|
Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
|
||||||
|
Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{
|
||||||
|
Established: true, LocalIPv4: "127.0.0.1", PCSCF: []string{listener.LocalAddr().String()},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case message := <-received:
|
||||||
|
if message.Text != "Main menu" || message.From != "sip:[email protected]" || message.CallID != "network-ussd-1" {
|
||||||
|
t.Fatalf("received = %#v", message)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for inbound USSI")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-readyForClose:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for USSI MESSAGE acceptance")
|
||||||
|
}
|
||||||
|
if err := session.Close(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := <-serverDone; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveInboundUSSI(listener *net.UDPConn, nonce string, readyForClose chan<- struct{}) error {
|
||||||
|
packet := make([]byte, 65535)
|
||||||
|
count, remote, err := listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, headers, err := parseTestRequest(packet[:count])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
callID := headers["call-id"]
|
||||||
|
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", callID, headers["cseq"], []string{
|
||||||
|
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
|
||||||
|
}), remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count, remote, err = listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, headers, err = parseTestRequest(packet[:count])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], []string{
|
||||||
|
"Contact: " + headers["contact"] + ";expires=600",
|
||||||
|
}), remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := buildUSSDBody("Main menu")
|
||||||
|
request := []byte(strings.Join([]string{
|
||||||
|
"MESSAGE sip:[email protected] SIP/2.0",
|
||||||
|
"Via: SIP/2.0/UDP " + listener.LocalAddr().String() + ";branch=z9hG4bKussd",
|
||||||
|
"From: <sip:[email protected]>;tag=gw",
|
||||||
|
"To: <sip:[email protected]>",
|
||||||
|
"P-Asserted-Identity: <sip:[email protected]>",
|
||||||
|
"Call-ID: network-ussd-1",
|
||||||
|
"CSeq: 1 MESSAGE",
|
||||||
|
"Content-Type: application/vnd.3gpp.ussd",
|
||||||
|
"Content-Transfer-Encoding: binary",
|
||||||
|
fmt.Sprintf("Content-Length: %d", len(body)), "", "",
|
||||||
|
}, "\r\n"))
|
||||||
|
request = append(request, body...)
|
||||||
|
if _, err = listener.WriteToUDP(request, remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count, remote, err = listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response, err := parseSIPResponse(packet[:count])
|
||||||
|
if err != nil || response.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("USSI MESSAGE response = (%#v, %v)", response, err)
|
||||||
|
}
|
||||||
|
close(readyForClose)
|
||||||
|
|
||||||
|
count, remote, err = listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, headers, err = parseTestRequest(packet[:count])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if headers["expires"] != "0" {
|
||||||
|
return errors.New("expected deregistration")
|
||||||
|
}
|
||||||
|
_, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], nil), remote)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionReceivesMalformedSMSBestEffort(t *testing.T) {
|
||||||
|
request := &sipRequest{
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"content-type": {smsContentType},
|
||||||
|
"content-transfer-encoding": {"binary"},
|
||||||
|
"call-id": {"malformed-test"},
|
||||||
|
"p-asserted-identity": {"<sip:[email protected]>"},
|
||||||
|
},
|
||||||
|
Body: []byte{0x01, 0x2a, 0x00, 0x00, 0x03, 0xff, 0xff, 0xff},
|
||||||
|
}
|
||||||
|
|
||||||
|
received := make(chan ReceivedSMS, 1)
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{
|
||||||
|
Logger: slog.Default(),
|
||||||
|
OnSMS: func(_ context.Context, message ReceivedSMS) error {
|
||||||
|
received <- message
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
request: vowifi.IMSRequest{DeviceID: "ec20", Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"}},
|
||||||
|
conn: &fakeConn{},
|
||||||
|
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||||
|
fromTag: "tag",
|
||||||
|
nextRPReference: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
session.processSMSMessage(request)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case message := <-received:
|
||||||
|
if message.DecodeError == "" {
|
||||||
|
t.Fatal("expected DecodeError to be set")
|
||||||
|
}
|
||||||
|
if message.RawRPDU == "" || message.RawTPDU == "" {
|
||||||
|
t.Fatalf("expected raw payloads to be preserved, got %#v", message)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for best-effort SMS callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionAllowsSMSWhenContactConfirmed(t *testing.T) {
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{Logger: slog.Default()}},
|
||||||
|
request: vowifi.IMSRequest{
|
||||||
|
Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"},
|
||||||
|
},
|
||||||
|
smsContactConfirmed: true,
|
||||||
|
evidence: vowifi.IMSEvidence{
|
||||||
|
Registered: true,
|
||||||
|
RegistrationState: "registered",
|
||||||
|
},
|
||||||
|
expiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence, err := session.EnableSMS(context.Background())
|
||||||
|
if err != nil || !evidence.Ready {
|
||||||
|
t.Fatalf("EnableSMS() = (%#v, %v), want ready when contact confirmed", evidence, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRequiresSMSContactConfirmationByDefault(t *testing.T) {
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{Logger: slog.Default()}},
|
||||||
|
request: vowifi.IMSRequest{
|
||||||
|
Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"},
|
||||||
|
},
|
||||||
|
evidence: vowifi.IMSEvidence{
|
||||||
|
Registered: true,
|
||||||
|
RegistrationState: "registered",
|
||||||
|
},
|
||||||
|
expiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence, err := session.EnableSMS(context.Background())
|
||||||
|
if !errors.Is(err, ErrSMSCapabilityNotConfirmed) || evidence.Ready {
|
||||||
|
t.Fatalf("EnableSMS() = (%#v, %v), want not-ready", evidence, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUSSDBody(text string) []byte {
|
||||||
|
encoded, dcs, err := encodeUSSDBody(text)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return append([]byte{byte(len(encoded) + 1), byte(*dcs)}, encoded...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionSendsUSSIOverIMS(t *testing.T) {
|
||||||
|
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
_ = listener.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
serverDone := make(chan error, 1)
|
||||||
|
readyForClose := make(chan struct{})
|
||||||
|
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||||
|
go func() { serverDone <- serveOutboundUSSI(listener, nonce, readyForClose) }()
|
||||||
|
provider, err := NewProvider(
|
||||||
|
smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}},
|
||||||
|
Config{
|
||||||
|
PCSCF: listener.LocalAddr().String(), LocalAddress: "127.0.0.1",
|
||||||
|
Transport: "udp", TransactionTimeout: 3 * time.Second, SecurityMode: SecurityDisabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session, err := provider.Start(context.Background(), vowifi.IMSRequest{
|
||||||
|
DeviceID: "ec20",
|
||||||
|
Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
|
||||||
|
Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{
|
||||||
|
Established: true, LocalIPv4: "127.0.0.1", PCSCF: []string{listener.LocalAddr().String()},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := session.(vowifi.USSISender).SendUSSI(context.Background(), vowifi.USSISubmitRequest{Code: "*100#"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendUSSI error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != "final" || result.Text != "Reply" || result.SIPCode != 200 {
|
||||||
|
t.Fatalf("SendUSSI result = %#v", result)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-readyForClose:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for USSI transaction to complete")
|
||||||
|
}
|
||||||
|
if err := session.Close(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := <-serverDone; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveOutboundUSSI(listener *net.UDPConn, nonce string, readyForClose chan<- struct{}) error {
|
||||||
|
packet := make([]byte, 65535)
|
||||||
|
count, remote, err := listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, headers, err := parseTestRequest(packet[:count])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
registerCallID := headers["call-id"]
|
||||||
|
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", registerCallID, headers["cseq"], []string{
|
||||||
|
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
|
||||||
|
}), remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count, remote, err = listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, headers, err = parseTestRequest(packet[:count])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], []string{
|
||||||
|
"Contact: " + headers["contact"] + ";expires=600",
|
||||||
|
}), remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count, remote, err = listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := parseSIPPacket(packet[:count])
|
||||||
|
if err != nil || message.Request == nil {
|
||||||
|
return fmt.Errorf("outbound MESSAGE parse: %v", err)
|
||||||
|
}
|
||||||
|
if message.Request.Method != "MESSAGE" ||
|
||||||
|
!strings.HasPrefix(message.Request.URI, "sip:") ||
|
||||||
|
strings.ToLower(message.Request.value("Content-Type")) != ussiContentType ||
|
||||||
|
message.Request.value("Request-Disposition") != "no-fork" ||
|
||||||
|
message.Request.value("Allow") != "MESSAGE" {
|
||||||
|
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
|
||||||
|
}
|
||||||
|
_, _, text := extractUSSDString(message.Request.Body)
|
||||||
|
if text != "*100#" {
|
||||||
|
return fmt.Errorf("USSI text = %q, want *100#", text)
|
||||||
|
}
|
||||||
|
replyBody := buildUSSDBody("Reply")
|
||||||
|
reply := []byte(strings.Join([]string{
|
||||||
|
"SIP/2.0 200 OK",
|
||||||
|
"Call-ID: " + message.Request.value("Call-ID"),
|
||||||
|
"CSeq: " + message.Request.value("CSeq"),
|
||||||
|
"Content-Type: application/vnd.3gpp.ussd",
|
||||||
|
"Content-Transfer-Encoding: binary",
|
||||||
|
fmt.Sprintf("Content-Length: %d", len(replyBody)), "", "",
|
||||||
|
}, "\r\n"))
|
||||||
|
reply = append(reply, replyBody...)
|
||||||
|
if _, err = listener.WriteToUDP(reply, remote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
close(readyForClose)
|
||||||
|
|
||||||
|
count, remote, err = listener.ReadFromUDP(packet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, headers, err = parseTestRequest(packet[:count])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if headers["expires"] != "0" {
|
||||||
|
return errors.New("expected deregistration")
|
||||||
|
}
|
||||||
|
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeConn is a minimal net.Conn useful for tests that only need LocalAddr
|
||||||
|
// to succeed and do not care about the actual SIP MESSAGE delivery report.
|
||||||
|
type fakeConn struct{}
|
||||||
|
|
||||||
|
func (*fakeConn) Read([]byte) (int, error) { return 0, errors.New("fakeConn: closed") }
|
||||||
|
func (*fakeConn) Write(source []byte) (int, error) { return len(source), nil }
|
||||||
|
func (*fakeConn) Close() error { return nil }
|
||||||
|
func (*fakeConn) LocalAddr() net.Addr {
|
||||||
|
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
|
||||||
|
}
|
||||||
|
func (*fakeConn) RemoteAddr() net.Addr {
|
||||||
|
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
|
||||||
|
}
|
||||||
|
func (*fakeConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (*fakeConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (*fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|||||||
@@ -560,6 +560,35 @@ func (orchestrator *Orchestrator) SendSMS(
|
|||||||
return sender.SendSMS(ctx, request)
|
return sender.SendSMS(ctx, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendUSSI submits a USSD dialog turn through the currently registered IMS
|
||||||
|
// session. USSI only requires IMS registration — it does not depend on the
|
||||||
|
// +g.3gpp.smsip contact being confirmed, so the readiness gate is IMSReady
|
||||||
|
// alone (unlike SendSMS which also requires SMSReady).
|
||||||
|
func (orchestrator *Orchestrator) SendUSSI(
|
||||||
|
ctx context.Context,
|
||||||
|
request USSISubmitRequest,
|
||||||
|
) (USSISubmitResult, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if err := orchestrator.lockOperation(ctx); err != nil {
|
||||||
|
return USSISubmitResult{}, err
|
||||||
|
}
|
||||||
|
defer orchestrator.unlockOperation()
|
||||||
|
orchestrator.mu.Lock()
|
||||||
|
resources := orchestrator.resources
|
||||||
|
ready := orchestrator.state.IMSReady
|
||||||
|
orchestrator.mu.Unlock()
|
||||||
|
if resources == nil || resources.ims == nil || !ready {
|
||||||
|
return USSISubmitResult{}, ErrUSSINotReady
|
||||||
|
}
|
||||||
|
sender, ok := resources.ims.(USSISender)
|
||||||
|
if !ok {
|
||||||
|
return USSISubmitResult{}, ErrUSSINotReady
|
||||||
|
}
|
||||||
|
return sender.SendUSSI(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
func (orchestrator *Orchestrator) Calls() ([]Call, error) {
|
func (orchestrator *Orchestrator) Calls() ([]Call, error) {
|
||||||
orchestrator.mu.Lock()
|
orchestrator.mu.Lock()
|
||||||
resources := orchestrator.resources
|
resources := orchestrator.resources
|
||||||
|
|||||||
@@ -120,13 +120,13 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
|
|||||||
name: "three digit MNC is preserved",
|
name: "three digit MNC is preserved",
|
||||||
identity: SIMIdentity{
|
identity: SIMIdentity{
|
||||||
ICCID: "one",
|
ICCID: "one",
|
||||||
HomeMCC: "310",
|
HomeMCC: "999",
|
||||||
HomeMNC: "260",
|
HomeMNC: "260",
|
||||||
},
|
},
|
||||||
want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org",
|
want: "epdg.epc.mnc260.mcc999.pub.3gppnetwork.org",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AT&T 310280 uses carrier endpoint",
|
name: "AT&T 310280 uses carrier bundle ePDG",
|
||||||
identity: SIMIdentity{
|
identity: SIMIdentity{
|
||||||
ICCID: "8901000000000000001",
|
ICCID: "8901000000000000001",
|
||||||
IMSI: "310280000000001",
|
IMSI: "310280000000001",
|
||||||
|
|||||||
@@ -318,6 +318,27 @@ func (manager *Manager) SendSMS(
|
|||||||
return item.orchestrator.SendSMS(ctx, request)
|
return item.orchestrator.SendSMS(ctx, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) SendUSSI(
|
||||||
|
ctx context.Context,
|
||||||
|
deviceID string,
|
||||||
|
request vowifi.USSISubmitRequest,
|
||||||
|
) (vowifi.USSISubmitResult, error) {
|
||||||
|
if err := manager.Ensure(ctx, deviceID); err != nil {
|
||||||
|
return vowifi.USSISubmitResult{}, err
|
||||||
|
}
|
||||||
|
manager.mu.Lock()
|
||||||
|
if manager.closed {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return vowifi.USSISubmitResult{}, ErrClosed
|
||||||
|
}
|
||||||
|
item := manager.entries[deviceID]
|
||||||
|
manager.mu.Unlock()
|
||||||
|
if item == nil {
|
||||||
|
return vowifi.USSISubmitResult{}, ErrNotRegistered
|
||||||
|
}
|
||||||
|
return item.orchestrator.SendUSSI(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
func (manager *Manager) Calls(deviceID string) ([]vowifi.Call, error) {
|
func (manager *Manager) Calls(deviceID string) ([]vowifi.Call, error) {
|
||||||
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
|
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ var (
|
|||||||
ErrTunnelNotEstablished = errors.New("vowifi: tunnel is not established")
|
ErrTunnelNotEstablished = errors.New("vowifi: tunnel is not established")
|
||||||
ErrIMSNotRegistered = errors.New("vowifi: IMS is not registered")
|
ErrIMSNotRegistered = errors.New("vowifi: IMS is not registered")
|
||||||
ErrSMSNotReady = errors.New("vowifi: SMS over IMS is not ready")
|
ErrSMSNotReady = errors.New("vowifi: SMS over IMS is not ready")
|
||||||
|
ErrUSSINotReady = errors.New("vowifi: USSI over IMS is not ready")
|
||||||
ErrEAPAuthenticationRejected = errors.New("vowifi: EAP-AKA authentication rejected")
|
ErrEAPAuthenticationRejected = errors.New("vowifi: EAP-AKA authentication rejected")
|
||||||
ErrResponderAUTHRequired = errors.New("vowifi: verified IKE responder AUTH is required")
|
ErrResponderAUTHRequired = errors.New("vowifi: verified IKE responder AUTH is required")
|
||||||
// ErrCleanupIncomplete marks a teardown that released its local IMS, tunnel,
|
// ErrCleanupIncomplete marks a teardown that released its local IMS, tunnel,
|
||||||
@@ -297,6 +298,27 @@ type SMSSubmitResult struct {
|
|||||||
PartResults []SMSSubmitPart `json:"partResults"`
|
PartResults []SMSSubmitPart `json:"partResults"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// USSISubmitRequest is one USSD dialog turn over IMS (3GPP TS 24.390). The
|
||||||
|
// first turn carries the service code in Code; a follow-up turn on an open
|
||||||
|
// dialog carries the menu reply in Input and leaves Code empty.
|
||||||
|
type USSISubmitRequest struct {
|
||||||
|
Code string
|
||||||
|
Input string
|
||||||
|
}
|
||||||
|
|
||||||
|
// USSISubmitResult mirrors the device.USSDResult shape so the HTTP layer can
|
||||||
|
// present USSI and cellular CUSD results uniformly.
|
||||||
|
type USSISubmitResult struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Raw string `json:"raw,omitempty"`
|
||||||
|
DCS *int `json:"dcs,omitempty"`
|
||||||
|
Continueable bool `json:"continueable,omitempty"`
|
||||||
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
|
SIPCode int `json:"sipCode,omitempty"`
|
||||||
|
SubmissionStatus string `json:"submissionStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type PhoneRecord struct {
|
type PhoneRecord struct {
|
||||||
ICCID string
|
ICCID string
|
||||||
Number string
|
Number string
|
||||||
@@ -397,6 +419,13 @@ type SMSSender interface {
|
|||||||
SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error)
|
SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// USSISender is an optional capability of a registered IMS session. Unlike SMS
|
||||||
|
// it does not require the +g.3gpp.smsip contact to be confirmed — USSI rides
|
||||||
|
// directly on a SIP MESSAGE with application/vnd.3gpp.ussd (TS 24.390).
|
||||||
|
type USSISender interface {
|
||||||
|
SendUSSI(context.Context, USSISubmitRequest) (USSISubmitResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Call describes one IMS call and reports whether an RTP media stream is
|
// Call describes one IMS call and reports whether an RTP media stream is
|
||||||
// available to an authenticated extension.
|
// available to an authenticated extension.
|
||||||
type Call struct {
|
type Call struct {
|
||||||
|
|||||||
+60
-10
@@ -3,9 +3,9 @@
|
|||||||
# vocat install / update script for systemd and OpenWrt/procd deployments.
|
# vocat install / update script for systemd and OpenWrt/procd deployments.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash install.sh [version] # run directly when already root
|
# bash install.sh [--check-env] [--skip-vowifi-check] [version] # run directly when already root
|
||||||
# sudo bash install.sh [version] # run through sudo as a normal user
|
# sudo bash install.sh [--check-env] [--skip-vowifi-check] [version] # run through sudo as a normal user
|
||||||
# bash install.sh --check-env # check VoWiFi host prerequisites
|
# bash install.sh --check-env # check VoWiFi host prerequisites
|
||||||
#
|
#
|
||||||
# Behavior:
|
# Behavior:
|
||||||
# - Prompts for script language (中文 / English) as soon as it runs.
|
# - Prompts for script language (中文 / English) as soon as it runs.
|
||||||
@@ -195,6 +195,44 @@ install_linux_ip_tool() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_qmi_support() {
|
||||||
|
msg "正在检查 QMI 命令行工具..." "Checking QMI command-line utilities..."
|
||||||
|
if command -v qmicli >/dev/null 2>&1 && command -v qmi-network >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
||||||
|
opkg update >/dev/null 2>&1 || true
|
||||||
|
local pkgs=""
|
||||||
|
opkg_has_package qmi-utils && pkgs="$pkgs qmi-utils"
|
||||||
|
opkg_has_package libqmi && pkgs="$pkgs libqmi"
|
||||||
|
if [ -z "$pkgs" ]; then
|
||||||
|
pkgs="qmi-utils libqmi"
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
opkg install $pkgs >/dev/null 2>&1 || true
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y libqmi-utils || true
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y libqmi-utils || true
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
pacman -Sy --noconfirm libqmi || true
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache qmi-utils || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v qmicli >/dev/null 2>&1 && command -v qmi-network >/dev/null 2>&1; then
|
||||||
|
msg "QMI 命令行工具已就绪。" "QMI command-line utilities are ready."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
die \
|
||||||
|
"无法安装或找到 qmicli/qmi-network。请安装系统提供的 libqmi/qmi-utils 软件包后重试。" \
|
||||||
|
"Could not install or find qmicli/qmi-network. Install your distribution's libqmi/qmi-utils package and retry."
|
||||||
|
}
|
||||||
|
|
||||||
install_pcsc_support() {
|
install_pcsc_support() {
|
||||||
msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..."
|
msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..."
|
||||||
local installed=0
|
local installed=0
|
||||||
@@ -203,6 +241,7 @@ install_pcsc_support() {
|
|||||||
local packages=""
|
local packages=""
|
||||||
opkg_has_package pcscd && packages="$packages pcscd"
|
opkg_has_package pcscd && packages="$packages pcscd"
|
||||||
opkg_has_package ccid && packages="$packages ccid"
|
opkg_has_package ccid && packages="$packages ccid"
|
||||||
|
opkg_has_package libccid && packages="$packages libccid"
|
||||||
if [ -n "$packages" ]; then
|
if [ -n "$packages" ]; then
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
opkg install $packages >/dev/null 2>&1 && installed=1 || true
|
opkg install $packages >/dev/null 2>&1 && installed=1 || true
|
||||||
@@ -263,8 +302,8 @@ check_vowifi_environment() {
|
|||||||
"The OpenWrt/Kwrt kernel $(uname -r) lacks NETLINK_XFRM and its feed has no matching kmod-ipsec. Use a firmware built with matching kmod-ipsec, kmod-ipsec4/6, crypto-authenc, CBC, AES and SHA1 modules. Never force kmods from another kernel. Use --skip-vowifi-check only for non-VoWiFi operation."
|
"The OpenWrt/Kwrt kernel $(uname -r) lacks NETLINK_XFRM and its feed has no matching kmod-ipsec. Use a firmware built with matching kmod-ipsec, kmod-ipsec4/6, crypto-authenc, CBC, AES and SHA1 modules. Never force kmods from another kernel. Use --skip-vowifi-check only for non-VoWiFi operation."
|
||||||
fi
|
fi
|
||||||
die \
|
die \
|
||||||
"当前 Linux 内核不支持 XFRM/IPsec,VoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 HMAC-SHA1。" \
|
"当前 Linux 内核不支持 XFRM/IPsec,VoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 HMAC-SHA1;若仅使用非 VoWiFi 功能(蜂窝短信/数据等),可重新运行安装脚本并加 --skip-vowifi-check。" \
|
||||||
"This Linux kernel lacks XFRM/IPsec required by VoWiFi IMS. Enable CONFIG_XFRM, CONFIG_XFRM_USER, CONFIG_INET_ESP, CONFIG_INET6_ESP, AES-CBC and HMAC-SHA1."
|
"This Linux kernel lacks XFRM/IPsec required by VoWiFi IMS. Enable CONFIG_XFRM, CONFIG_XFRM_USER, CONFIG_INET_ESP, CONFIG_INET6_ESP, AES-CBC and HMAC-SHA1; or re-run with --skip-vowifi-check if you only need non-VoWiFi features (cellular SMS/data)."
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Skip if already installed at the same version ---------------------------
|
# --- Skip if already installed at the same version ---------------------------
|
||||||
@@ -330,7 +369,7 @@ download_and_verify() {
|
|||||||
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
|
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
|
||||||
chmod 0755 "${VOCAT_TMP}/vocat"
|
chmod 0755 "${VOCAT_TMP}/vocat"
|
||||||
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
|
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
|
||||||
"Downloaded binary cannot run on this system; keeping the installed version." \
|
"下载的二进制文件无法在此系统上运行;未更改当前安装的版本。" \
|
||||||
"The downloaded binary cannot run on this host; the installed version was not changed."
|
"The downloaded binary cannot run on this host; the installed version was not changed."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,8 +398,18 @@ INITIAL_ADMIN_PASSWORD=""
|
|||||||
bootstrap_admin() {
|
bootstrap_admin() {
|
||||||
local candidate="${1:-$BINARY_PATH}"
|
local candidate="${1:-$BINARY_PATH}"
|
||||||
local secret result
|
local secret result
|
||||||
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
if command -v od >/dev/null 2>&1; then
|
||||||
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
|
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||||
|
elif command -v hexdump >/dev/null 2>&1; then
|
||||||
|
secret=$(hexdump -n 16 -e '16/1 "%02x"' /dev/urandom)
|
||||||
|
elif command -v openssl >/dev/null 2>&1; then
|
||||||
|
secret=$(openssl rand -hex 16 2>/dev/null || true)
|
||||||
|
elif command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
secret=$(head -c 32 /dev/urandom | sha256sum | awk '{print substr($1, 1, 32)}')
|
||||||
|
else
|
||||||
|
secret=$(tr -dc 'a-f0-9' < /dev/urandom | head -c 32)
|
||||||
|
fi
|
||||||
|
[ -n "$secret" ] || die "生成随机密钥失败。" "Failed to generate a random secret."
|
||||||
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||||
die \
|
die \
|
||||||
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
|
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
|
||||||
@@ -472,7 +521,7 @@ write_service() {
|
|||||||
write_openwrt_init
|
write_openwrt_init
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected."
|
die "不支持的服务管理器。" "Neither systemd nor OpenWrt procd was detected."
|
||||||
}
|
}
|
||||||
|
|
||||||
enable_and_start() {
|
enable_and_start() {
|
||||||
@@ -514,7 +563,7 @@ enable_and_start() {
|
|||||||
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
||||||
"$OPENWRT_INIT_PATH" restart || true
|
"$OPENWRT_INIT_PATH" restart || true
|
||||||
fi
|
fi
|
||||||
die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start."
|
die "OpenWrt vocat 服务启动失败。" "The OpenWrt vocat service failed to start."
|
||||||
fi
|
fi
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable vocat
|
systemctl enable vocat
|
||||||
@@ -538,6 +587,7 @@ enable_and_start() {
|
|||||||
|
|
||||||
# --- Main --------------------------------------------------------------------
|
# --- Main --------------------------------------------------------------------
|
||||||
detect_arch
|
detect_arch
|
||||||
|
install_qmi_support
|
||||||
install_pcsc_support
|
install_pcsc_support
|
||||||
check_vowifi_environment
|
check_vowifi_environment
|
||||||
if [ "$CHECK_ENV" -eq 1 ]; then
|
if [ "$CHECK_ENV" -eq 1 ]; then
|
||||||
|
|||||||
Generated
+1896
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: set this to true or false
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { WindowConsoleRegular, WarningRegular } from "@fluentui/react-icons";
|
import { WindowConsoleRegular, WarningRegular } from "@fluentui/react-icons";
|
||||||
import { api } from "../../api";
|
import { api } from "../../api";
|
||||||
import { Button, Input, Select } from "../ui";
|
import { Button, Input, Select, Switch } from "../ui";
|
||||||
import { AT_COMMAND_GROUPS } from "./atCommands";
|
import { AT_COMMAND_GROUPS } from "./atCommands";
|
||||||
import { AtLogEntry, AtTypingBubble, type AtLogItem } from "./AtLogEntry";
|
import { AtLogEntry, AtTypingBubble, type AtLogItem } from "./AtLogEntry";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
@@ -19,6 +19,7 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt
|
|||||||
const [template, setTemplate] = useState("");
|
const [template, setTemplate] = useState("");
|
||||||
const [timeoutMs, setTimeoutMs] = useState<number>(10000);
|
const [timeoutMs, setTimeoutMs] = useState<number>(10000);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [force, setForce] = useState(false);
|
||||||
const [log, setLog] = useState<AtLogItem[]>([]);
|
const [log, setLog] = useState<AtLogItem[]>([]);
|
||||||
|
|
||||||
const hasAtPort = String(atPort || "").trim().length > 0;
|
const hasAtPort = String(atPort || "").trim().length > 0;
|
||||||
@@ -40,7 +41,7 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt
|
|||||||
try {
|
try {
|
||||||
const res = await api<{ ok?: boolean; response?: string; result?: string }>(`/devices/${deviceId}/actions/at`, {
|
const res = await api<{ ok?: boolean; response?: string; result?: string }>(`/devices/${deviceId}/actions/at`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { cmd: command, timeoutMs: timeoutMs || 10000 },
|
body: { cmd: command, timeoutMs: timeoutMs || 10000, force },
|
||||||
});
|
});
|
||||||
setLog((prev) => [
|
setLog((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
@@ -120,6 +121,13 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-orange-600 dark:text-orange-400">
|
||||||
|
<WarningRegular className="text-base" />
|
||||||
|
<span>{t("强制模式允许发送默认被拦截的 AT 指令(如切网、拨号、短信、USSD),误操作可能导致断网或费用扣除。")}</span>
|
||||||
|
</div>
|
||||||
|
<Switch checked={force} onChange={setForce} ariaLabel={t("强制发送 AT 指令")} />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 flex flex-col items-center justify-center rounded-xl border border-orange-100 bg-orange-50 p-8 dark:border-orange-900/50 dark:bg-orange-900/20">
|
<div className="mt-4 flex flex-col items-center justify-center rounded-xl border border-orange-100 bg-orange-50 p-8 dark:border-orange-900/50 dark:bg-orange-900/20">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { AtTypingBubble } from "./AtLogEntry";
|
|||||||
import { tf, useI18n } from "../../lib/i18n";
|
import { tf, useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
interface UssdResult {
|
interface UssdResult {
|
||||||
status?: number;
|
status?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
rawText?: string;
|
rawText?: string;
|
||||||
dcs?: number;
|
dcs?: number;
|
||||||
@@ -40,7 +40,7 @@ export function DeviceUssdTab({ deviceId }: { deviceId: string }) {
|
|||||||
const res = await api<{ result?: Record<string, unknown>; channel?: string }>(path, { method: "POST", body });
|
const res = await api<{ result?: Record<string, unknown>; channel?: string }>(path, { method: "POST", body });
|
||||||
const r = (res?.result || {}) as Record<string, unknown>;
|
const r = (res?.result || {}) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
status: r.status as number | undefined,
|
status: r.status as string | undefined,
|
||||||
text: (r.text as string) || "",
|
text: (r.text as string) || "",
|
||||||
rawText: ((r.rawText as string) || (r.rawXml as string) || "") as string,
|
rawText: ((r.rawText as string) || (r.rawXml as string) || "") as string,
|
||||||
dcs: r.dcs as number | undefined,
|
dcs: r.dcs as number | undefined,
|
||||||
@@ -59,15 +59,15 @@ export function DeviceUssdTab({ deviceId }: { deviceId: string }) {
|
|||||||
const v = await callUssd(command);
|
const v = await callUssd(command);
|
||||||
if (v.channel) setChannel(v.channel);
|
if (v.channel) setChannel(v.channel);
|
||||||
const text = v.text || v.rawText || t("[空响应]");
|
const text = v.text || v.rawText || t("[空响应]");
|
||||||
if (v.status === 5) {
|
if (v.status === "failed") {
|
||||||
setLog((prev) => [...prev, { ts: Date.now(), type: "err", content: tf("[网络不支持/无响应]\n{text}", { text }), dcs: v.dcs, channel: v.channel }]);
|
setLog((prev) => [...prev, { ts: Date.now(), type: "err", content: tf("[网络不支持/无响应]\n{text}", { text }), dcs: v.dcs, channel: v.channel }]);
|
||||||
clearSession();
|
clearSession();
|
||||||
} else if (v.status === 2) {
|
} else if (v.status === "terminated") {
|
||||||
setLog((prev) => [...prev, { ts: Date.now(), type: "err", content: tf("[被网络终止]\n{text}", { text }), dcs: v.dcs, channel: v.channel }]);
|
setLog((prev) => [...prev, { ts: Date.now(), type: "err", content: tf("[被网络终止]\n{text}", { text }), dcs: v.dcs, channel: v.channel }]);
|
||||||
clearSession();
|
clearSession();
|
||||||
} else {
|
} else {
|
||||||
setLog((prev) => [...prev, { ts: Date.now(), type: "res", content: text, dcs: v.dcs, channel: v.channel }]);
|
setLog((prev) => [...prev, { ts: Date.now(), type: "res", content: text, dcs: v.dcs, channel: v.channel }]);
|
||||||
if (v.status === 1 && v.sessionId) setSessionId(v.sessionId);
|
if (v.status === "awaiting_input" && v.sessionId) setSessionId(v.sessionId);
|
||||||
else clearSession();
|
else clearSession();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export function DiscoveredDeviceRow({
|
|||||||
? t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。")
|
? t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。")
|
||||||
: device.discoveryIssue === "pcsc_driver_missing"
|
: device.discoveryIssue === "pcsc_driver_missing"
|
||||||
? t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。")
|
? t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。")
|
||||||
: "";
|
: device.discoveryIssue === "at_port_missing"
|
||||||
|
? t("已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 `echo 2c7c <pid> | sudo tee /sys/bus/usb-serial/drivers/option1/new_id` 后重扫,或用 AT+QCFG 切到 QMI+AT 组态。")
|
||||||
|
: "";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -98,7 +98,12 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||||
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
<FieldRow
|
||||||
|
label={t("运营商配置")}
|
||||||
|
value={!rt?.carrierProfile || rt.carrierProfile === "standard-3gpp" ? "3GPP Standard" : rt.carrierProfile}
|
||||||
|
monospace
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||||
|
|||||||
@@ -685,6 +685,8 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"AT 终端暂不可用": "AT terminal unavailable",
|
"AT 终端暂不可用": "AT terminal unavailable",
|
||||||
"AT=串口 / QMI=纯 QMI": "AT=serial / QMI=pure QMI",
|
"AT=串口 / QMI=纯 QMI": "AT=serial / QMI=pure QMI",
|
||||||
"AT=传统串口 / QMI=纯 QMI": "AT=legacy serial / QMI=pure QMI",
|
"AT=传统串口 / QMI=纯 QMI": "AT=legacy serial / QMI=pure QMI",
|
||||||
|
"强制发送 AT 指令": "Force-send AT command",
|
||||||
|
"强制模式允许发送默认被拦截的 AT 指令(如切网、拨号、短信、USSD),误操作可能导致断网或费用扣除。": "Force mode allows sending AT commands that are normally blocked (e.g. mode switching, dialing, SMS, USSD). Mistakes may disconnect the network or incur charges.",
|
||||||
"E911地址": "E911 Address",
|
"E911地址": "E911 Address",
|
||||||
"E911地址设置页面打开失败": "Failed to open the E911 address setup page",
|
"E911地址设置页面打开失败": "Failed to open the E911 address setup page",
|
||||||
"IMEI 绑定": "IMEI Binding",
|
"IMEI 绑定": "IMEI Binding",
|
||||||
@@ -820,6 +822,8 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"匹配依据": "Profile Match",
|
"匹配依据": "Profile Match",
|
||||||
"方向": "Direction",
|
"方向": "Direction",
|
||||||
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
||||||
|
"已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 `echo 2c7c <pid> | sudo tee /sys/bus/usb-serial/drivers/option1/new_id` 后重扫,或用 AT+QCFG 切到 QMI+AT 组态。":
|
||||||
|
"The modem was discovered, but no AT serial port was found. This usually means the `option` driver does not recognize this PID, or the module is in an MBIM/RNDIS composition. Run `echo 2c7c <pid> | sudo tee /sys/bus/usb-serial/drivers/option1/new_id` then rescan, or use AT+QCFG to switch to a QMI+AT composition.",
|
||||||
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
||||||
"No usable AT port was found (serial interfaces may still be enumerating). The system retries automatically; you can also rescan now.",
|
"No usable AT port was found (serial interfaces may still be enumerating). The system retries automatically; you can also rescan now.",
|
||||||
"无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试": "Cannot read the device IMEI (the control port may be stuck); run AT!RESET or switch the USB composition and retry",
|
"无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试": "Cannot read the device IMEI (the control port may be stuck); run AT!RESET or switch the USB composition and retry",
|
||||||
@@ -944,6 +948,7 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"通知重试发送失败": "Notification resend failed",
|
"通知重试发送失败": "Notification resend failed",
|
||||||
"通知重试发送成功": "Notification resent",
|
"通知重试发送成功": "Notification resent",
|
||||||
"配置存储在数据库中,部分字段可能需要重启生效": "Configuration is stored in the database; some fields may require a restart to take effect",
|
"配置存储在数据库中,部分字段可能需要重启生效": "Configuration is stored in the database; some fields may require a restart to take effect",
|
||||||
|
"配置策略": "Profile Policy",
|
||||||
"配置已保存,但部分变更需要重启服务后生效": "Configuration saved, but some changes require a service restart",
|
"配置已保存,但部分变更需要重启服务后生效": "Configuration saved, but some changes require a service restart",
|
||||||
"采样中断": "Sampling interrupted",
|
"采样中断": "Sampling interrupted",
|
||||||
"重启中": "Rebooting",
|
"重启中": "Rebooting",
|
||||||
|
|||||||
@@ -381,6 +381,10 @@ export default function DevicesPage() {
|
|||||||
message.warning(t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。"));
|
message.warning(t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (d.discoveryIssue === "at_port_missing") {
|
||||||
|
message.warning(t("已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 `echo 2c7c <pid> | sudo tee /sys/bus/usb-serial/drivers/option1/new_id` 后重扫,或用 AT+QCFG 切到 QMI+AT 组态。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (d.degraded) {
|
if (d.degraded) {
|
||||||
message.warning(t("无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试"));
|
message.warning(t("无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试"));
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user