mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-19 22:33:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0318670f49 | ||
|
|
d06afdb076 | ||
|
|
b56acc0e3a | ||
|
|
60cc636969 | ||
|
|
73a72680ad | ||
|
|
60501d4831 | ||
|
|
2c843d82a4 | ||
|
|
ad66456d2f | ||
|
|
161aa667c9 | ||
|
|
8137fc875b | ||
|
|
1df338f9b3 | ||
|
|
20f91fac72 | ||
|
|
30880f6612 | ||
|
|
fdb2ac45ce | ||
|
|
95299a0a39 | ||
|
|
1a4032d013 | ||
|
|
6ec950bfd2 | ||
|
|
04dd026e95 | ||
|
|
a2a72dfd30 | ||
|
|
67079c99b9 | ||
|
|
f949001480 | ||
|
|
fa8afb9571 | ||
|
|
bfda29193a | ||
|
|
ed64fd428a | ||
|
|
f84a1f99b1 |
@@ -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
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
Copyright (c) 2013, Dustin L. Howett. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
The views and conclusions contained in the software and documentation are
|
||||||
|
those of the authors and should not be interpreted as representing official
|
||||||
|
policies, either expressed or implied, of the FreeBSD Project.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Parts of this package were made available under the license covering the Go
|
||||||
|
language and all attended core libraries. That license follows.
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||||
|
used to endorse or promote products derived from this software without
|
||||||
|
specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -96,6 +96,14 @@ to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
|||||||
If matching kernel modules are unavailable, use a firmware that includes them;
|
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`;
|
||||||
@@ -197,6 +205,18 @@ 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`, and Alpine uses `apk add 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.
|
||||||
@@ -212,6 +232,10 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
|||||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
||||||
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
||||||
|
|
||||||
|
User-supplied Apple carrier bundles can be converted into reviewable,
|
||||||
|
allow-listed carrier profiles with `vocat carrier import-ipcc`; see
|
||||||
|
[docs/CARRIER_IPCC_IMPORT.md](docs/CARRIER_IPCC_IMPORT.md).
|
||||||
|
|
||||||
Administrator credentials are stored only in SQLite. Initialize an empty
|
Administrator credentials are stored only in SQLite. Initialize an empty
|
||||||
database once with `vocat bootstrap-admin`; environment variables and JSON
|
database once with `vocat bootstrap-admin`; environment variables and JSON
|
||||||
configuration cannot set or overwrite the administrator username or password.
|
configuration cannot set or overwrite the administrator username or password.
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"vocat/internal/config"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runCarrier(args []string, stdout io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errors.New("usage: vocat carrier import-ipcc [flags] FILE.ipcc")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "import-ipcc":
|
||||||
|
return runCarrierImportIPCC(args[1:], stdout)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown carrier subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCarrierImportIPCC(args []string, stdout io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("carrier import-ipcc", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
var bundle string
|
||||||
|
var profileID string
|
||||||
|
var profileDir string
|
||||||
|
var install bool
|
||||||
|
var documentOnly bool
|
||||||
|
flags.StringVar(&bundle, "bundle", "", "bundle name when an IPCC contains more than one carrier bundle")
|
||||||
|
flags.StringVar(&profileID, "id", "", "override the generated carrier profile ID")
|
||||||
|
flags.StringVar(&profileDir, "profile-dir", "", "installation directory (default: next to the VoCat database)")
|
||||||
|
flags.BoolVar(&install, "install", false, "atomically install the reviewed generated profile")
|
||||||
|
flags.BoolVar(&documentOnly, "document-only", false, "print only the generated carrier profile document")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 1 {
|
||||||
|
return errors.New("usage: vocat carrier import-ipcc [--bundle NAME] [--id ID] [--document-only] [--install] [--profile-dir DIR] FILE.ipcc")
|
||||||
|
}
|
||||||
|
if documentOnly && install {
|
||||||
|
return errors.New("--document-only and --install cannot be used together")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(profileDir) != "" && !install {
|
||||||
|
return errors.New("--profile-dir requires --install")
|
||||||
|
}
|
||||||
|
result, err := vowifi.ImportCarrierIPCC(flags.Arg(0), vowifi.IPCCImportOptions{
|
||||||
|
Bundle: bundle,
|
||||||
|
ProfileID: profileID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if documentOnly {
|
||||||
|
_, err := stdout.Write(result.Document)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
installedPath := ""
|
||||||
|
if install {
|
||||||
|
profileDir = strings.TrimSpace(profileDir)
|
||||||
|
if profileDir == "" {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load configuration for carrier profile directory: %w", err)
|
||||||
|
}
|
||||||
|
profileDir = filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||||
|
}
|
||||||
|
installedPath, err = vowifi.InstallCarrierIPCCResult(result, profileDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if absolute, absoluteErr := filepath.Abs(installedPath); absoluteErr == nil {
|
||||||
|
installedPath = absolute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output := struct {
|
||||||
|
vowifi.IPCCImportResult
|
||||||
|
InstalledPath string `json:"installed_path,omitempty"`
|
||||||
|
RestartRequired bool `json:"restart_required,omitempty"`
|
||||||
|
}{
|
||||||
|
IPCCImportResult: result,
|
||||||
|
InstalledPath: installedPath,
|
||||||
|
RestartRequired: installedPath != "",
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(output)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"howett.net/plist"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunCarrierImportIPCCPreviewsAndInstallsExplicitly(t *testing.T) {
|
||||||
|
archivePath := filepath.Join(t.TempDir(), "test.ipcc")
|
||||||
|
file, err := os.Create(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := zip.NewWriter(file)
|
||||||
|
entry, err := archive.Create("Payload/Test.bundle/carrier.plist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := plist.NewEncoder(&encoded).Encode(map[string]any{
|
||||||
|
"CarrierName": "Test Carrier",
|
||||||
|
"SupportedSIMs": []any{"99901"},
|
||||||
|
"SupportedPLMNs": []any{"99901"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview bytes.Buffer
|
||||||
|
if err := runCarrier([]string{"import-ipcc", "--document-only", archivePath}, &preview); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var document struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(preview.Bytes(), &document); err != nil || document.Version != 1 {
|
||||||
|
t.Fatalf("preview = %q, version=%d, error=%v", preview.String(), document.Version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installDir := t.TempDir()
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := runCarrier([]string{
|
||||||
|
"import-ipcc", "--id", "cli-test", "--install", "--profile-dir", installDir, archivePath,
|
||||||
|
}, &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var installed struct {
|
||||||
|
InstalledPath string `json:"installed_path"`
|
||||||
|
RestartRequired bool `json:"restart_required"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &installed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !installed.RestartRequired || filepath.Base(installed.InstalledPath) != "cli-test.json" {
|
||||||
|
t.Fatalf("install output = %s", output.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(installDir, "cli-test.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ Usage:
|
|||||||
vocat serve Run the server in the foreground (use from a TTY when
|
vocat serve Run the server in the foreground (use from a TTY when
|
||||||
vocat without arguments would enter the menu).
|
vocat without arguments would enter the menu).
|
||||||
vocat version Print the build version and exit.
|
vocat version Print the build version and exit.
|
||||||
|
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths.
|
||||||
|
Use --repair-dji-qmi on Linux to restore the factory-ID
|
||||||
|
DJI/Baiwang 2ca3:4006 AT/QMI interface bindings and wake
|
||||||
|
QMI without changing NV.
|
||||||
|
vocat carrier import-ipcc [flags] FILE.ipcc
|
||||||
|
Convert an Apple carrier bundle into a reviewable VoCat
|
||||||
|
profile. Preview is the default; --install writes it to
|
||||||
|
carrier-profiles.d and takes effect after restart.
|
||||||
|
Flags: --bundle NAME --id ID --document-only --install
|
||||||
|
--profile-dir DIR.
|
||||||
vocat update Check GitHub for a newer release and self-update.
|
vocat update Check GitHub for a newer release and self-update.
|
||||||
Flags:
|
Flags:
|
||||||
--check Only report whether an update is available.
|
--check Only report whether an update is available.
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/modem"
|
||||||
|
"vocat/internal/pcsc"
|
||||||
|
"vocat/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type doctorCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Evidence any `json:"evidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type doctorReport struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Checks []doctorCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type djiQMIRepairResult struct {
|
||||||
|
USBName string `json:"usb_name"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
USBDevice string `json:"usb_device"`
|
||||||
|
OriginalDriver string `json:"original_driver,omitempty"`
|
||||||
|
SerialInterfaces []string `json:"serial_interfaces,omitempty"`
|
||||||
|
SerialDevices []string `json:"serial_devices,omitempty"`
|
||||||
|
ATDevice string `json:"at_device,omitempty"`
|
||||||
|
ControlDevice string `json:"control_device"`
|
||||||
|
NetworkInterface string `json:"network_interface,omitempty"`
|
||||||
|
QMIProbe string `json:"qmi_probe"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDoctor(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(os.Stderr)
|
||||||
|
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
|
||||||
|
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
|
||||||
|
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password")
|
||||||
|
repairDJI := flags.Bool("repair-dji-qmi", false, "bind DJI 2ca3:4006 interfaces 0-3 to option and interface 4 to qmi_wwan, then assert DTR (Linux/root only; no NV write)")
|
||||||
|
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
|
||||||
|
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 || *timeout <= 0 || *timeout > time.Minute {
|
||||||
|
return errors.New("usage: vocat doctor [--repair-dji-qmi] [--proxy host:port] [--proxy-username name] [--proxy-password-env ENV] [--json]")
|
||||||
|
}
|
||||||
|
report := doctorReport{Time: time.Now().UTC(), OS: runtime.GOOS, Arch: runtime.GOARCH}
|
||||||
|
add := func(name, status, code, message string, evidence any) {
|
||||||
|
report.Checks = append(report.Checks, doctorCheck{Name: name, Status: status, Code: code, Message: message, Evidence: evidence})
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := os.ReadFile("/proc/version"); err == nil && strings.Contains(strings.ToLower(string(data)), "microsoft") {
|
||||||
|
add("host", "warning", "wsl_usbip_detected", "WSL/USBIP detected; QMI control transfers may time out even when /dev/cdc-wdm exists", nil)
|
||||||
|
} else {
|
||||||
|
add("host", "passed", "native_host", "No WSL kernel marker detected", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
if *repairDJI {
|
||||||
|
result, err := repairDJIQMI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("repair DJI QMI binding: %w", err)
|
||||||
|
}
|
||||||
|
add("dji_qmi_repair", "passed", "dji_usb_interfaces_repaired", "DJI serial interfaces 0-3 were bound to option and interface 4 to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx)
|
||||||
|
if discoverErr != nil {
|
||||||
|
add("modem_discovery", "failed", "modem_discovery_failed", discoverErr.Error(), nil)
|
||||||
|
} else if len(candidates) == 0 {
|
||||||
|
add("modem_discovery", "warning", "no_modem", "No USB modem was discovered", nil)
|
||||||
|
} else {
|
||||||
|
add("modem_discovery", "passed", "modem_discovered", fmt.Sprintf("Discovered %d modem candidate(s)", len(candidates)), candidates)
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
name := "modem:" + candidate.ID
|
||||||
|
if candidate.HasATPort() {
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 5*time.Second))
|
||||||
|
client, openErr := (modem.SerialOpener{}).Open(probeContext, candidate.ATPort)
|
||||||
|
if openErr != nil {
|
||||||
|
add(name+":at", "warning", "at_open_failed", openErr.Error(), candidate.ATPort.OpenPath())
|
||||||
|
} else {
|
||||||
|
response, commandErr := client.Execute(probeContext, "AT+CFUN?")
|
||||||
|
_ = client.Close()
|
||||||
|
if commandErr != nil {
|
||||||
|
add(name+":at", "warning", "at_probe_failed", commandErr.Error(), candidate.ATPort.OpenPath())
|
||||||
|
} else {
|
||||||
|
add(name+":at", "passed", "at_ready", "AT control channel responded to a read-only CFUN query", response.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancelProbe()
|
||||||
|
} else {
|
||||||
|
add(name+":at", "failed", "at_missing", "No AT port was selected", nil)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(candidate.QMIControl) == "" {
|
||||||
|
add(name+":qmi", "warning", "qmi_missing", "No cdc-wdm/QMI control node was discovered", nil)
|
||||||
|
} else if qmicli, lookErr := exec.LookPath("qmicli"); lookErr != nil {
|
||||||
|
add(name+":qmi", "warning", "qmicli_missing", "QMI node exists but qmicli is unavailable for an active DMS check", candidate.QMIControl)
|
||||||
|
} else {
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 8*time.Second))
|
||||||
|
command := exec.CommandContext(probeContext, qmicli, "-d", candidate.QMIControl, "--dms-get-operating-mode")
|
||||||
|
output, commandErr := command.CombinedOutput()
|
||||||
|
message := strings.TrimSpace(string(output))
|
||||||
|
cancelProbe()
|
||||||
|
if commandErr != nil {
|
||||||
|
code := "qmi_cid_failed"
|
||||||
|
if errors.Is(probeContext.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timed out") {
|
||||||
|
code = "qmi_cid_timeout"
|
||||||
|
}
|
||||||
|
add(name+":qmi", "failed", code, "qmicli DMS client allocation/read failed", message)
|
||||||
|
} else {
|
||||||
|
add(name+":qmi", "passed", "qmi_dms_ready", "qmicli allocated DMS and completed a read-only request", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readers, readerErr := pcsc.New().Readers(ctx)
|
||||||
|
if readerErr == nil {
|
||||||
|
add("pcsc", "passed", "pcsc_ready", fmt.Sprintf("PC/SC reported %d reader(s)", len(readers)), readers)
|
||||||
|
} else if errors.Is(readerErr, pcsc.ErrUnsupported) || errors.Is(readerErr, pcsc.ErrUnavailable) {
|
||||||
|
add("pcsc", "warning", "pcsc_unavailable", readerErr.Error(), nil)
|
||||||
|
} else {
|
||||||
|
add("pcsc", "failed", "pcsc_failed", readerErr.Error(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(*proxyAddress) != "" {
|
||||||
|
password := os.Getenv(strings.TrimSpace(*passwordEnv))
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
result, probeErr := proxy.ProbeSOCKS5(probeContext, *proxyAddress, *proxyUsername, password, *timeout)
|
||||||
|
cancelProbe()
|
||||||
|
status := "passed"
|
||||||
|
if probeErr != nil {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
add("proxy_udp", status, result.Diagnosis, result.Hint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *jsonOutput {
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(report)
|
||||||
|
}
|
||||||
|
for _, check := range report.Checks {
|
||||||
|
fmt.Printf("%-8s %-26s %-28s %s\n", strings.ToUpper(check.Status), check.Name, check.Code, check.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func minDuration(left, right time.Duration) time.Duration {
|
||||||
|
if left < right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return right
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
djiVendorID = "2ca3"
|
||||||
|
djiProductID = "4006"
|
||||||
|
djiFirstSerialIndex = 0
|
||||||
|
djiLastSerialIndex = 3
|
||||||
|
djiATIndex = 2
|
||||||
|
djiQMIIndex = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type usbControlTransfer struct {
|
||||||
|
RequestType uint8
|
||||||
|
Request uint8
|
||||||
|
Value uint16
|
||||||
|
Index uint16
|
||||||
|
Length uint16
|
||||||
|
Timeout uint32
|
||||||
|
Data uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) {
|
||||||
|
qmicli, err := exec.LookPath("qmicli")
|
||||||
|
if err != nil {
|
||||||
|
return djiQMIRepairResult{}, errors.New("qmicli is required to verify DJI QMI readiness; install libqmi-utils on Debian/Ubuntu/Fedora, libqmi on Arch Linux, or qmi-utils on Alpine")
|
||||||
|
}
|
||||||
|
return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) {
|
||||||
|
return repairDJIQMIAt(attemptContext, "/sys", "/dev", qmicli)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryDJIQMI(
|
||||||
|
ctx context.Context,
|
||||||
|
maxAttempts int,
|
||||||
|
delay time.Duration,
|
||||||
|
attempt func(context.Context) (djiQMIRepairResult, error),
|
||||||
|
) (djiQMIRepairResult, error) {
|
||||||
|
var result djiQMIRepairResult
|
||||||
|
var err error
|
||||||
|
for attemptNumber := 1; attemptNumber <= maxAttempts; attemptNumber++ {
|
||||||
|
result, err = attempt(ctx)
|
||||||
|
result.Attempts = attemptNumber
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(time.Duration(attemptNumber) * delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return result, errors.Join(err, ctx.Err())
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (result djiQMIRepairResult, returnErr error) {
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
entries, err := os.ReadDir(usbRoot)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("read USB topology: %w", err)
|
||||||
|
}
|
||||||
|
var usbNames []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
devicePath := filepath.Join(usbRoot, entry.Name())
|
||||||
|
vendor, vendorErr := readTrimmedFile(filepath.Join(devicePath, "idVendor"))
|
||||||
|
product, productErr := readTrimmedFile(filepath.Join(devicePath, "idProduct"))
|
||||||
|
if vendorErr == nil && productErr == nil &&
|
||||||
|
strings.EqualFold(vendor, djiVendorID) && strings.EqualFold(product, djiProductID) {
|
||||||
|
usbNames = append(usbNames, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(usbNames) != 1 {
|
||||||
|
return result, fmt.Errorf("expected exactly one DJI %s:%s USB device, found %d", djiVendorID, djiProductID, len(usbNames))
|
||||||
|
}
|
||||||
|
result.USBName = usbNames[0]
|
||||||
|
result.Interface = fmt.Sprintf("%s:1.%d", result.USBName, djiQMIIndex)
|
||||||
|
devicePath := filepath.Join(usbRoot, result.USBName)
|
||||||
|
interfacePath := filepath.Join(usbRoot, result.Interface)
|
||||||
|
if _, err := os.Stat(interfacePath); err != nil {
|
||||||
|
return result, fmt.Errorf("DJI QMI interface %s unavailable: %w", result.Interface, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
busNumber, err := readUSBNumber(filepath.Join(devicePath, "busnum"))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
deviceNumber, err := readUSBNumber(filepath.Join(devicePath, "devnum"))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber))
|
||||||
|
|
||||||
|
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||||
|
if err := ensureUSBDriverLoaded(ctx, driversRoot, "qmi_wwan", "qmi_wwan"); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := ensureUSBDriverLoaded(ctx, driversRoot, "option", "option"); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// qmi_wwan's USB dynamic ID is device-wide. Leaving it installed makes it
|
||||||
|
// probe every vendor-specific interface after a USBIP reconnect; on this DJI
|
||||||
|
// composition that can turn interfaces 1-3 into bogus cdc-wdm devices and
|
||||||
|
// remove the AT port. Remove it before detaching anything, then add it only
|
||||||
|
// briefly below while interface 4 is the sole unbound interface.
|
||||||
|
qmiDriverRoot := filepath.Join(driversRoot, "qmi_wwan")
|
||||||
|
if err := removeDynamicUSBID(qmiDriverRoot, djiVendorID+" "+djiProductID); err != nil {
|
||||||
|
return result, fmt.Errorf("remove broad DJI qmi_wwan dynamic ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialInterfaces, serialDevices, atDevice, err := bindDJISerialInterfaces(ctx, sysRoot, devRoot, usbRoot, driversRoot, result.USBName)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.SerialInterfaces = serialInterfaces
|
||||||
|
result.SerialDevices = serialDevices
|
||||||
|
result.ATDevice = atDevice
|
||||||
|
|
||||||
|
result.OriginalDriver = usbInterfaceDriver(interfacePath)
|
||||||
|
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
|
||||||
|
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver)
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaceDetached := false
|
||||||
|
restoreOriginal := func() {
|
||||||
|
if !interfaceDetached {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
||||||
|
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
|
||||||
|
}
|
||||||
|
switch result.OriginalDriver {
|
||||||
|
case "qmi_wwan":
|
||||||
|
_ = bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface)
|
||||||
|
case "option":
|
||||||
|
_ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if returnErr != nil {
|
||||||
|
restoreOriginal()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if result.OriginalDriver != "" {
|
||||||
|
if err := writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "unbind"), result.Interface); err != nil {
|
||||||
|
return result, fmt.Errorf("unbind %s from %s: %w", result.OriginalDriver, result.Interface, err)
|
||||||
|
}
|
||||||
|
interfaceDetached = true
|
||||||
|
}
|
||||||
|
if err := assertUSBDTR(result.USBDevice, djiQMIIndex); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
result.ControlDevice = firstDeviceNode(filepath.Join(interfacePath, "usbmisc"), devRoot, "cdc-wdm")
|
||||||
|
result.NetworkInterface = firstEntryName(filepath.Join(interfacePath, "net"), "")
|
||||||
|
if result.ControlDevice != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return result, fmt.Errorf("qmi_wwan bound but no cdc-wdm node appeared for %s", result.Interface)
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
// The requested driver topology is now established. A later DMS timeout is
|
||||||
|
// a QMI/USBIP readiness problem, so do not roll interface 4 back to option.
|
||||||
|
interfaceDetached = false
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
|
||||||
|
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
||||||
|
probeContextErr := probeContext.Err()
|
||||||
|
cancelProbe()
|
||||||
|
result.QMIProbe = strings.TrimSpace(string(output))
|
||||||
|
if probeErr != nil {
|
||||||
|
if probeContextErr != nil {
|
||||||
|
probeErr = errors.Join(probeErr, probeContextErr)
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindDJIQMIInterface(driverRoot, interfacePath, interfaceName string) (returnErr error) {
|
||||||
|
bindPath := filepath.Join(driverRoot, "bind")
|
||||||
|
dynamicIDAdded := false
|
||||||
|
defer func() {
|
||||||
|
if dynamicIDAdded {
|
||||||
|
removeErr := removeDynamicUSBID(driverRoot, djiVendorID+" "+djiProductID)
|
||||||
|
if returnErr == nil && removeErr != nil {
|
||||||
|
returnErr = fmt.Errorf("remove temporary DJI qmi_wwan dynamic ID: %w", removeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := writeSysfs(bindPath, interfaceName); err != nil {
|
||||||
|
newIDErr := writeSysfs(filepath.Join(driverRoot, "new_id"), djiVendorID+" "+djiProductID)
|
||||||
|
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
|
||||||
|
return fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
|
||||||
|
}
|
||||||
|
dynamicIDAdded = true
|
||||||
|
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
|
||||||
|
if retryErr := writeSysfs(bindPath, interfaceName); retryErr != nil {
|
||||||
|
return fmt.Errorf("bind qmi_wwan to %s: %w", interfaceName, retryErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
|
||||||
|
return fmt.Errorf("interface %s driver is %q after qmi_wwan bind", interfaceName, driver)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureUSBDriverLoaded(ctx context.Context, driversRoot, driverName, moduleName string) error {
|
||||||
|
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("inspect %s driver: %w", driverName, err)
|
||||||
|
}
|
||||||
|
modprobe, err := exec.LookPath("modprobe")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s is not loaded and modprobe is unavailable", driverName)
|
||||||
|
}
|
||||||
|
if output, loadErr := exec.CommandContext(ctx, modprobe, moduleName).CombinedOutput(); loadErr != nil {
|
||||||
|
return fmt.Errorf("load %s: %w: %s", moduleName, loadErr, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err != nil {
|
||||||
|
return fmt.Errorf("%s driver is unavailable after loading module %s: %w", driverName, moduleName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindDJISerialInterfaces(
|
||||||
|
ctx context.Context,
|
||||||
|
sysRoot, devRoot, usbRoot, driversRoot, usbName string,
|
||||||
|
) ([]string, []string, string, error) {
|
||||||
|
interfaceNames := make([]string, 0, djiLastSerialIndex-djiFirstSerialIndex+1)
|
||||||
|
interfacePaths := make([]string, 0, cap(interfaceNames))
|
||||||
|
needsDynamicID := false
|
||||||
|
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||||
|
name := fmt.Sprintf("%s:1.%d", usbName, index)
|
||||||
|
path := filepath.Join(usbRoot, name)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("DJI serial interface %s unavailable: %w", name, err)
|
||||||
|
}
|
||||||
|
driver := usbInterfaceDriver(path)
|
||||||
|
if driver != "" && driver != "option" && driver != "qmi_wwan" {
|
||||||
|
return nil, nil, "", fmt.Errorf("refusing to replace unexpected driver %q on %s", driver, name)
|
||||||
|
}
|
||||||
|
interfaceNames = append(interfaceNames, name)
|
||||||
|
interfacePaths = append(interfacePaths, path)
|
||||||
|
needsDynamicID = needsDynamicID || driver != "option"
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsDynamicID {
|
||||||
|
// Detach every false QMI claim before option's new_id triggers probing.
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
if usbInterfaceDriver(path) != "qmi_wwan" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "unbind"), interfaceNames[index]); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("unbind qmi_wwan from serial interface %s: %w", interfaceNames[index], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionSerialRoot := filepath.Join(sysRoot, "bus", "usb-serial", "drivers", "option1")
|
||||||
|
if _, err := os.Stat(optionSerialRoot); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("option USB-serial driver is unavailable: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeSysfs(filepath.Join(optionSerialRoot, "new_id"), djiVendorID+" "+djiProductID); err != nil && !errors.Is(err, syscall.EEXIST) {
|
||||||
|
return nil, nil, "", fmt.Errorf("register DJI option dynamic ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
if usbInterfaceDriver(path) == "option" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writeSysfs(filepath.Join(driversRoot, "option", "bind"), interfaceNames[index]); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("bind option to %s: %w", interfaceNames[index], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
if driver := usbInterfaceDriver(path); driver != "option" {
|
||||||
|
return nil, nil, "", fmt.Errorf("serial interface %s driver is %q after option bind", interfaceNames[index], driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
serialDevices := make([]string, len(interfacePaths))
|
||||||
|
for {
|
||||||
|
complete := true
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
name := firstEntryName(path, "ttyUSB")
|
||||||
|
if name == "" {
|
||||||
|
complete = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serialDevices[index] = filepath.Join(devRoot, name)
|
||||||
|
}
|
||||||
|
if complete {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, nil, "", fmt.Errorf("option bound but not all ttyUSB nodes appeared for %s", usbName)
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return interfaceNames, serialDevices, serialDevices[djiATIndex-djiFirstSerialIndex], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDynamicUSBID(driverRoot, id string) error {
|
||||||
|
path := filepath.Join(driverRoot, "remove_id")
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeSysfs(path, id); err != nil && !errors.Is(err, syscall.ENODEV) && !errors.Is(err, syscall.ENOENT) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertUSBDTR(devicePath string, interfaceIndex int) error {
|
||||||
|
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open USB device %s: %w", devicePath, err)
|
||||||
|
}
|
||||||
|
defer unix.Close(fd)
|
||||||
|
if err := setUSBControlLineState(fd, interfaceIndex, false); err != nil {
|
||||||
|
return fmt.Errorf("clear CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if err := setUSBControlLineState(fd, interfaceIndex, true); err != nil {
|
||||||
|
return fmt.Errorf("assert CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
||||||
|
}
|
||||||
|
// QDC507 acknowledges the control transfer before its QMI firmware is ready.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUSBControlLineState(fd, interfaceIndex int, dtr bool) error {
|
||||||
|
var value uint16
|
||||||
|
if dtr {
|
||||||
|
value = 1 // USB_CDC_CTRL_DTR
|
||||||
|
}
|
||||||
|
transfer := usbControlTransfer{
|
||||||
|
RequestType: 0x21, // host-to-device, class, interface
|
||||||
|
Request: 0x22, // USB_CDC_REQ_SET_CONTROL_LINE_STATE
|
||||||
|
Value: value,
|
||||||
|
Index: uint16(interfaceIndex),
|
||||||
|
Timeout: 5000,
|
||||||
|
}
|
||||||
|
const ioctlDirectionReadWrite = uintptr(3)
|
||||||
|
request := ioctlDirectionReadWrite<<30 |
|
||||||
|
uintptr(unsafe.Sizeof(transfer))<<16 |
|
||||||
|
uintptr('U')<<8
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), request, uintptr(unsafe.Pointer(&transfer)))
|
||||||
|
if errno != 0 {
|
||||||
|
return errno
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTrimmedFile(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUSBNumber(path string) (int, error) {
|
||||||
|
value, err := readTrimmedFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read %s: %w", filepath.Base(path), err)
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(value)
|
||||||
|
if err != nil || number < 1 || number > 999 {
|
||||||
|
return 0, fmt.Errorf("invalid %s %q", filepath.Base(path), value)
|
||||||
|
}
|
||||||
|
return number, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usbInterfaceDriver(interfacePath string) string {
|
||||||
|
resolved, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Base(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSysfs(path, value string) error {
|
||||||
|
file, err := os.OpenFile(path, os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, writeErr := file.WriteString(value)
|
||||||
|
closeErr := file.Close()
|
||||||
|
return errors.Join(writeErr, closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDeviceNode(directory, devRoot, prefix string) string {
|
||||||
|
name := firstEntryName(directory, prefix)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(devRoot, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstEntryName(directory, prefix string) string {
|
||||||
|
entries, err := os.ReadDir(directory)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), prefix) {
|
||||||
|
return entry.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDJIUSBControlTransferLayout(t *testing.T) {
|
||||||
|
var transfer usbControlTransfer
|
||||||
|
if got := unsafe.Sizeof(transfer); got != 24 {
|
||||||
|
t.Fatalf("usbControlTransfer size = %d, want 24", got)
|
||||||
|
}
|
||||||
|
if transfer.RequestType != 0 || transfer.Request != 0 {
|
||||||
|
t.Fatal("zero-value transfer unexpectedly initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadUSBNumber(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "busnum")
|
||||||
|
if err := os.WriteFile(path, []byte("12\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := readUSBNumber(path); err != nil || got != 12 {
|
||||||
|
t.Fatalf("readUSBNumber() = %d, %v, want 12, nil", got, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := readUSBNumber(path); err == nil {
|
||||||
|
t.Fatal("readUSBNumber(0) unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "missing")
|
||||||
|
if err := writeSysfs(path, "value"); err == nil {
|
||||||
|
t.Fatal("writeSysfs(missing) unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("missing sysfs path was created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairDJIQMIRequiresQMICLIBeforeUSBAccess(t *testing.T) {
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
_, err := repairDJIQMI(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("repairDJIQMI() unexpectedly succeeded without qmicli")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "qmicli is required") || !strings.Contains(err.Error(), "libqmi-utils") {
|
||||||
|
t.Fatalf("repairDJIQMI() error = %q, want an actionable qmicli prerequisite error", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "DTR repair attempt") || strings.Contains(err.Error(), "USB topology") {
|
||||||
|
t.Fatalf("repairDJIQMI() touched the repair path before checking qmicli: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDJISerialInterfaceLayout(t *testing.T) {
|
||||||
|
if djiFirstSerialIndex != 0 || djiLastSerialIndex != 3 || djiATIndex != 2 || djiQMIIndex != 4 {
|
||||||
|
t.Fatalf(
|
||||||
|
"DJI interface layout = serial %d-%d, AT %d, QMI %d; want serial 0-3, AT 2, QMI 4",
|
||||||
|
djiFirstSerialIndex,
|
||||||
|
djiLastSerialIndex,
|
||||||
|
djiATIndex,
|
||||||
|
djiQMIIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindDJISerialInterfacesAlreadyCorrect(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||||
|
optionRoot := filepath.Join(driversRoot, "option")
|
||||||
|
if err := os.MkdirAll(optionRoot, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||||
|
interfacePath := filepath.Join(usbRoot, fmt.Sprintf("1-1:1.%d", index))
|
||||||
|
if err := os.MkdirAll(filepath.Join(interfacePath, fmt.Sprintf("ttyUSB%d", index)), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(optionRoot, filepath.Join(interfacePath, "driver")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaces, devices, atDevice, err := bindDJISerialInterfaces(
|
||||||
|
context.Background(),
|
||||||
|
sysRoot,
|
||||||
|
devRoot,
|
||||||
|
usbRoot,
|
||||||
|
driversRoot,
|
||||||
|
"1-1",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bindDJISerialInterfaces() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(interfaces) != 4 || interfaces[2] != "1-1:1.2" {
|
||||||
|
t.Fatalf("interfaces = %#v, want four interfaces with AT at 1-1:1.2", interfaces)
|
||||||
|
}
|
||||||
|
if len(devices) != 4 || devices[2] != filepath.Join(devRoot, "ttyUSB2") {
|
||||||
|
t.Fatalf("devices = %#v, want four devices with AT at ttyUSB2", devices)
|
||||||
|
}
|
||||||
|
if atDevice != filepath.Join(devRoot, "ttyUSB2") {
|
||||||
|
t.Fatalf("AT device = %q, want %q", atDevice, filepath.Join(devRoot, "ttyUSB2"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
return djiQMIRepairResult{}, errors.New("transient QMI timeout")
|
||||||
|
}
|
||||||
|
return djiQMIRepairResult{ControlDevice: "/dev/cdc-wdm0"}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retryDJIQMI() error = %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 3 || result.Attempts != 3 {
|
||||||
|
t.Fatalf("attempts = %d, result.Attempts = %d, want 3", attempts, result.Attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDJIQMIStopsAfterBoundedAttempts(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
_, err := retryDJIQMI(context.Background(), 2, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
||||||
|
attempts++
|
||||||
|
return djiQMIRepairResult{}, errors.New("persistent failure")
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("retryDJIQMI() unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func repairDJIQMI(context.Context) (djiQMIRepairResult, error) {
|
||||||
|
return djiQMIRepairResult{}, errors.New("DJI QMI repair is supported only on Linux")
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDoctorHelpIsSuccessful(t *testing.T) {
|
||||||
|
if err := runDoctor([]string{"--help"}); err != nil {
|
||||||
|
t.Fatalf("runDoctor(--help) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,3 +29,29 @@ func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
|
|||||||
t.Fatal("installer replaces the current binary before validating database compatibility")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ func main() {
|
|||||||
logger.Error("update failed", "error", err)
|
logger.Error("update failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
case "doctor":
|
||||||
|
if err := runDoctor(rest); err != nil {
|
||||||
|
logger.Error("doctor failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "carrier":
|
||||||
|
if err := runCarrier(rest, os.Stdout); err != nil {
|
||||||
|
logger.Error("carrier command failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
case "menu":
|
case "menu":
|
||||||
if err := runMenu(logger); err != nil {
|
if err := runMenu(logger); err != nil {
|
||||||
logger.Error("menu failed", "error", err)
|
logger.Error("menu failed", "error", err)
|
||||||
@@ -120,6 +130,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load configuration: %w", err)
|
return fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
carrierProfileDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||||
|
if err := vowifi.LoadCarrierProfileDirectory(carrierProfileDir); err != nil {
|
||||||
|
return fmt.Errorf("load installed carrier profiles: %w", err)
|
||||||
|
}
|
||||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -809,6 +823,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)
|
||||||
|
|||||||
@@ -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` 架构;
|
||||||
@@ -175,6 +182,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`。
|
||||||
|
|
||||||
|
`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
|
||||||
|
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
||||||
@@ -195,6 +212,25 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
|||||||
|
|
||||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||||
|
|
||||||
|
## Apple IPCC 运营商规则导入
|
||||||
|
|
||||||
|
VoCat 可以离线解析用户提供的 `.ipcc`,将 Apple 的 XML/二进制 plist
|
||||||
|
转换为可审查的运营商 Profile。默认只预览,不会修改配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vocat carrier import-ipcc Carrier_iPhone.ipcc
|
||||||
|
```
|
||||||
|
|
||||||
|
确认警告和匹配范围后,使用 `--install` 安装;重启 VoCat 后生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vocat carrier import-ipcc --install Carrier_iPhone.ipcc
|
||||||
|
```
|
||||||
|
|
||||||
|
导入器不会复制关闭证书验证、绕过运营商授权、APN 凭据、紧急呼叫或
|
||||||
|
设备型号专属媒体参数。完整字段和冲突处理说明见
|
||||||
|
[CARRIER_IPCC_IMPORT.md](CARRIER_IPCC_IMPORT.md)。
|
||||||
|
|
||||||
## Telegram 机器人
|
## Telegram 机器人
|
||||||
|
|
||||||
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.43.0
|
||||||
|
howett.net/plist v1.0.1
|
||||||
modernc.org/sqlite v1.38.2
|
modernc.org/sqlite v1.38.2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
||||||
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
||||||
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -49,10 +50,13 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
|||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
|||||||
@@ -121,3 +121,22 @@ func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
|||||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCarrierForSIMRecognizesGiffgaffWithoutRelabelingGenericO2(t *testing.T) {
|
||||||
|
for _, identity := range []CarrierIdentity{
|
||||||
|
{IMSI: "234100000000001", GID1: "508FFFFF", MNCLength: 2},
|
||||||
|
{IMSI: "234100000000001", SPN: "GiffGaff", MNCLength: 2},
|
||||||
|
} {
|
||||||
|
plmn, name, country, ok := CarrierForSIM(identity)
|
||||||
|
if !ok || plmn != "23410" || name != "giffgaff" || country != "GB" {
|
||||||
|
t.Fatalf("giffgaff identity = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, name, _, ok := CarrierForSIM(CarrierIdentity{
|
||||||
|
IMSI: "234100000000001", MNCLength: 2,
|
||||||
|
})
|
||||||
|
if !ok || name == "giffgaff" {
|
||||||
|
t.Fatalf("generic O2 SIM was mislabeled as giffgaff: (%q, %v)", name, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -548,6 +548,14 @@ func decodeGSM7(septets []byte) (string, error) {
|
|||||||
return result.String(), nil
|
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
|
||||||
@@ -852,7 +860,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,21 @@ func TestParseCMGLPreservesUndecodableRecord(t *testing.T) {
|
|||||||
t.Fatalf("messages = %#v", messages)
|
t.Fatalf("messages = %#v", messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||||
|
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
||||||
|
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
|
||||||
|
// Built from the GSM-7 deliver vector by swapping the DCS to 0xF5
|
||||||
|
// and replacing the user data with three raw binary bytes.
|
||||||
|
message, err := decodeSMSPDU(
|
||||||
|
"000405912143F500F54210203040500003AABBCC",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode 8-bit: %v", err)
|
||||||
|
}
|
||||||
|
if message.Encoding != SMSEncoding8BitPDU ||
|
||||||
|
message.Text != "AABBCC" ||
|
||||||
|
message.RawUserData != "AABBCC" {
|
||||||
|
t.Fatalf("8-bit message = %#v", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-10
@@ -69,7 +69,12 @@ func (manager *Manager) readSnapshot(
|
|||||||
if ccidErr != nil {
|
if ccidErr != nil {
|
||||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||||
}
|
}
|
||||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
|
||||||
|
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
|
||||||
|
// timeout, and every refresh holds the device lock while it does so,
|
||||||
|
// starving the AT terminal. Only fall back to QMI when the AT CPIN
|
||||||
|
// probe already proved a card is present.
|
||||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||||
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
||||||
cancelQMI()
|
cancelQMI()
|
||||||
@@ -186,14 +191,18 @@ func (manager *Manager) readSnapshot(
|
|||||||
snapshot.RegistrationSource = "COPS"
|
snapshot.RegistrationSource = "COPS"
|
||||||
}
|
}
|
||||||
if snapshot.IMEI == "" {
|
if snapshot.IMEI == "" {
|
||||||
response, ok := optional("AT+CGSN")
|
// AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
|
||||||
if ok {
|
// returns the IMEI line but never a final OK, so it would block until the
|
||||||
snapshot.IMEI = parseIdentifier(
|
// caller's deadline (30s during a periodic refresh) and starve every other
|
||||||
response,
|
// device operation behind the lock. Give it an independent short timeout
|
||||||
[]string{"+CGSN:", "+GSN:"},
|
// and let the WWAN transport's drain discard the trailing stale bytes.
|
||||||
14,
|
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
|
||||||
17,
|
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
|
||||||
)
|
cancelCGSN()
|
||||||
|
if cgsnErr == nil {
|
||||||
|
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
|
||||||
|
snapshot.IMEI = imei
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||||
@@ -211,7 +220,6 @@ func (manager *Manager) readSnapshot(
|
|||||||
// Preserve a prior successful read across a transient QMI/AT failure.
|
// Preserve a prior successful read across a transient QMI/AT failure.
|
||||||
snapshot.IMEI = previousSnapshot.IMEI
|
snapshot.IMEI = previousSnapshot.IMEI
|
||||||
}
|
}
|
||||||
|
|
||||||
if response, ok := optional("AT+CFUN?"); ok {
|
if response, ok := optional("AT+CFUN?"); ok {
|
||||||
if mode, found := parseCFUN(response); found {
|
if mode, found := parseCFUN(response); found {
|
||||||
snapshot.OperatingMode = mode
|
snapshot.OperatingMode = mode
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/modem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lenientATClient answers every command with a bare CommandError and records
|
||||||
|
// the commands it saw. It lets snapshot tests exercise the full readSnapshot
|
||||||
|
// sequence without enumerating every step of the transcript.
|
||||||
|
type lenientATClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
commands []string
|
||||||
|
cgsnDelay time.Duration
|
||||||
|
cgsnIMEI string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) Execute(ctx context.Context, command string) (modem.Response, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.commands = append(c.commands, command)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if command == "ATI" {
|
||||||
|
return okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410"), nil
|
||||||
|
}
|
||||||
|
if command == "AT+CGSN" && c.cgsnDelay > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(c.cgsnDelay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if command == "AT+CGSN" && c.cgsnIMEI != "" {
|
||||||
|
return okResponse("+CGSN: " + c.cgsnIMEI), nil
|
||||||
|
}
|
||||||
|
return modem.Response{}, &modem.CommandError{Command: command, Final: "ERROR"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) WaitURC(context.Context, func(string) bool) (string, error) {
|
||||||
|
return "", errors.New("no URC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) Close() error { return nil }
|
||||||
|
|
||||||
|
func (c *lenientATClient) saw(command string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for _, seen := range c.commands {
|
||||||
|
if seen == command {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT+CGSN on some MHI modems returns the IMEI line but never a final OK, so it
|
||||||
|
// would block until the caller's deadline and hold the device lock for the
|
||||||
|
// whole periodic refresh. The snapshot must bound CGSN with its own short
|
||||||
|
// timeout instead of inheriting the refresh deadline.
|
||||||
|
func TestManagerRefreshBoundsCGSNTimeout(t *testing.T) {
|
||||||
|
client := &lenientATClient{cgsnDelay: 5 * time.Second}
|
||||||
|
manager, id := newStartedTestManager(t, client)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
snapshot, err := manager.Refresh(ctx, id)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
// CGSN times out after CommandTimeout (1s in the test manager); the rest
|
||||||
|
// of the snapshot is immediate. An un-bounded CGSN would wait for the
|
||||||
|
// 4s outer deadline (or worse, a real 30s refresh deadline).
|
||||||
|
if elapsed > 3*time.Second {
|
||||||
|
t.Fatalf("Refresh took %s; CGSN was not bounded by CommandTimeout", elapsed)
|
||||||
|
}
|
||||||
|
if !client.saw("AT+CGSN") {
|
||||||
|
t.Fatalf("CGSN was never sent; commands = %v", client.commands)
|
||||||
|
}
|
||||||
|
if snapshot.IMEI != "" {
|
||||||
|
t.Fatalf("IMEI = %q, want empty after CGSN timeout", snapshot.IMEI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing SIM must not fall back to the QMI UIM ICCID read: without a READY
|
||||||
|
// card that call blocks until its long timeout and starves the AT terminal
|
||||||
|
// behind the device lock.
|
||||||
|
func TestManagerRefreshSkipsQMIICCIDWithoutReadySIM(t *testing.T) {
|
||||||
|
// CGSN succeeds so the snapshot does not fall back to the QMI DMS IMEI
|
||||||
|
// read either; the test focuses on the UIM ICCID fallback being skipped
|
||||||
|
// without a READY card.
|
||||||
|
client := &lenientATClient{cgsnIMEI: "866241014372802"}
|
||||||
|
manager, err := NewManager(Options{
|
||||||
|
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
|
||||||
|
ID: "mhi-wwan0",
|
||||||
|
Product: "PCIe/MHI WWAN modem",
|
||||||
|
QMIControl: "/dev/wwan0qmi0",
|
||||||
|
NetworkInterface: "wwan0",
|
||||||
|
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
|
||||||
|
}}},
|
||||||
|
Opener: &staticOpener{client: client},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := manager.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||||
|
|
||||||
|
qmiCalls := 0
|
||||||
|
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||||
|
qmiCalls++
|
||||||
|
return nil, errors.New("QMI should not be opened without a SIM")
|
||||||
|
}
|
||||||
|
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
// Exactly one QMI open is expected: the immutable DMS IMEI read runs
|
||||||
|
// unconditionally for native QMI candidates (IMEI is hardware identity,
|
||||||
|
// independent of the card). The UIM ICCID fallback, which would block
|
||||||
|
// without a READY SIM, must be skipped.
|
||||||
|
if qmiCalls != 1 {
|
||||||
|
t.Fatalf("qmiRadioOpener called %d times, want 1 (DMS IMEI only, UIM ICCID must be skipped without a READY SIM)", qmiCalls)
|
||||||
|
}
|
||||||
|
for _, warning := range snapshot.Warnings {
|
||||||
|
if strings.Contains(warning, "QMI UIM") {
|
||||||
|
t.Fatalf("unexpected QMI ICCID warning: %q", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
// Keep feature-specific diagnostic strings together so additions to the proxy
|
||||||
|
// probe do not cause conflicts in the shared dictionary.
|
||||||
|
func init() {
|
||||||
|
zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall."
|
||||||
|
zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed."
|
||||||
|
zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed."
|
||||||
|
zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed."
|
||||||
|
}
|
||||||
+95
-13
@@ -12,9 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
quectelVendorID = "2c7c"
|
|
||||||
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 {
|
||||||
@@ -44,10 +48,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
entries = nil
|
entries = nil
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
return nil, fmt.Errorf("discover USB QMI modems: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Candidate modems are identified by kernel driver binding instead of a
|
||||||
|
// vendor-ID whitelist. qmi_wwan only binds Qualcomm QMI control interfaces,
|
||||||
|
// so any USB device with a bound interface exposes a live QMI channel. This
|
||||||
|
// keeps discovery vendor-neutral (SIMCom, Sierra, Telit and other
|
||||||
|
// Qualcomm-based modems are found automatically) while MBIM-only devices
|
||||||
|
// stay out, because cdc_mbim binds their control interface instead and the
|
||||||
|
// project has no MBIM backend.
|
||||||
|
qmiBound := d.qmiWWANBoundDevices()
|
||||||
|
|
||||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||||
devices := make(map[string]*discoveredUSBDevice)
|
devices := make(map[string]*discoveredUSBDevice)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -75,16 +88,24 @@ 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 !isSupportedUSBModem(vendorID, productID) {
|
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
||||||
|
// A bound qmi_wwan interface is the strongest vendor-neutral "this is
|
||||||
|
// a live QMI modem" signal, but it excludes Quectel modules running
|
||||||
|
// in a serial or RNDIS/ECM USB composition (no qmi_wwan binding).
|
||||||
|
// Re-admit them by vendor so their AT serial ports stay discoverable;
|
||||||
|
// the candidate is only kept if a ttyUSB/ttyACM node is actually
|
||||||
|
// found below, which is exactly the AT-bearing composition we want.
|
||||||
|
if !isQuectelUSBModem(vendorID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state := devices[deviceName]
|
state := devices[deviceName]
|
||||||
if state == nil {
|
if state == nil {
|
||||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||||
state = &discoveredUSBDevice{
|
state = &discoveredUSBDevice{
|
||||||
candidate: Candidate{
|
candidate: Candidate{
|
||||||
ID: candidateID(productID, serialNumber, deviceName),
|
ID: candidateID(vendorID, productID, serialNumber, deviceName),
|
||||||
VendorID: vendorID,
|
VendorID: vendorID,
|
||||||
ProductID: productID,
|
ProductID: productID,
|
||||||
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
||||||
@@ -134,6 +155,22 @@ 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() {
|
||||||
|
// A modem without a usable AT port cannot be driven by vocat, but it
|
||||||
|
// is far more useful to surface it with a discovery issue than to
|
||||||
|
// silently drop it: the operator sees the device is present and gets
|
||||||
|
// told why it is unusable. Two shapes land here:
|
||||||
|
// * qmi_wwan is bound but no ttyUSB/ttyACM exists — the option/qcserial
|
||||||
|
// driver did not claim the serial interfaces (often a missing PID
|
||||||
|
// in its device-ID table, common on Ubuntu for EG25-G carrier
|
||||||
|
// builds). The modem is alive; it just lacks an AT node.
|
||||||
|
// * no qmi_wwan binding (Quectel re-admitted by vendor) and no AT
|
||||||
|
// port — typically an MBIM/RNDIS/ECM composition. The module is on
|
||||||
|
// the bus but exposes no AT serial interface vocat can open.
|
||||||
|
// Both resolve the same operator action: add the PID to the option
|
||||||
|
// driver or switch the module to a QMI+AT composition.
|
||||||
|
state.candidate.DiscoveryIssue = "at_port_missing"
|
||||||
|
}
|
||||||
result = append(result, state.candidate)
|
result = append(result, state.candidate)
|
||||||
}
|
}
|
||||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||||
@@ -145,11 +182,6 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSupportedUSBModem(vendorID, productID string) bool {
|
|
||||||
return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID) ||
|
|
||||||
IsDJI4GUSB(vendorID, productID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
||||||
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
||||||
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
||||||
@@ -158,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
|
||||||
@@ -252,7 +293,7 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
|||||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||||
}
|
}
|
||||||
if len(group.ports) > 0 {
|
if len(group.ports) > 0 {
|
||||||
candidate.ATPort = group.ports[0]
|
candidate.ATPort = selectWWANATPort(group.ports)
|
||||||
}
|
}
|
||||||
if len(group.qmiNames) > 0 {
|
if len(group.qmiNames) > 0 {
|
||||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||||
@@ -263,6 +304,20 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectWWANATPort prefers the secondary AT port (…at1) over the primary
|
||||||
|
// (…at0) when both exist, falling back to the first AT port otherwise. Some
|
||||||
|
// Qualcomm MHI modems (notably the UFI dongle behind the OpenStick 410) answer
|
||||||
|
// on at1 immediately while at0 delays every response by 10-20 seconds, so the
|
||||||
|
// secondary port is the usable AT channel.
|
||||||
|
func selectWWANATPort(ports []Port) Port {
|
||||||
|
for _, port := range ports {
|
||||||
|
if port.InterfaceNumber == 1 {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports[0]
|
||||||
|
}
|
||||||
|
|
||||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||||
if !strings.HasPrefix(name, "wwan") {
|
if !strings.HasPrefix(name, "wwan") {
|
||||||
return "", "", 0, false
|
return "", "", 0, false
|
||||||
@@ -402,7 +457,34 @@ func readSerialAliases(root string) map[string]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func candidateID(productID, serialNumber, usbName string) string {
|
// qmiWWANBoundDevices returns the set of USB device paths (for example "1-6"
|
||||||
|
// or the hub-attached "1-4.3.2") that currently have at least one interface
|
||||||
|
// bound to the kernel's qmi_wwan driver. Interface entries in the driver
|
||||||
|
// directory are named "<device-path>:<interface>.<altsetting>", so the part
|
||||||
|
// before the first colon is the owning USB device. The qmi_wwan driver only
|
||||||
|
// binds Qualcomm QMI control interfaces, so membership doubles as a vendor-
|
||||||
|
// neutral "this is a live QMI modem" signal.
|
||||||
|
func (d *SysFSDiscoverer) qmiWWANBoundDevices() map[string]struct{} {
|
||||||
|
driverRoot := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||||
|
entries, err := os.ReadDir(driverRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
devices := make(map[string]struct{})
|
||||||
|
for _, entry := range entries {
|
||||||
|
// The driver directory also holds control files (bind, unbind, uevent,
|
||||||
|
// module, new_id, ...); only names containing a colon are interfaces.
|
||||||
|
deviceName, _, ok := strings.Cut(entry.Name(), ":")
|
||||||
|
if !ok || deviceName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices[deviceName] = struct{}{}
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
||||||
|
prefix := "usb-" + sanitizeID(vendorID)
|
||||||
serialNumber = strings.TrimSpace(serialNumber)
|
serialNumber = strings.TrimSpace(serialNumber)
|
||||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||||
@@ -411,9 +493,9 @@ func candidateID(productID, serialNumber, usbName string) string {
|
|||||||
// to the same hub into one entry. Include the physical USB topology in the
|
// to the same hub into one entry. Include the physical USB topology in the
|
||||||
// discovery key; configured devices remain stable through ATMapper's
|
// discovery key; configured devices remain stable through ATMapper's
|
||||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||||
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
||||||
}
|
}
|
||||||
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
return prefix + "-" + sanitizeID(productID+"-"+usbName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeID(value string) string {
|
func sanitizeID(value string) string {
|
||||||
|
|||||||
@@ -2,24 +2,24 @@ package modem
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestSupportedUSBModemIdentity(t *testing.T) {
|
func TestIsDJI4GUSBIdentity(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
vendorID string
|
vendorID string
|
||||||
productID string
|
productID string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{name: "Quectel", vendorID: "2c7c", productID: "0125", want: true},
|
|
||||||
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
||||||
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
||||||
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
||||||
|
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
|
||||||
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if got := isSupportedUSBModem(test.vendorID, test.productID); got != test.want {
|
if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
|
||||||
t.Fatalf("isSupportedUSBModem(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||||
|
|
||||||
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
||||||
candidates, err := discoverer.Discover(context.Background())
|
candidates, err := discoverer.Discover(context.Background())
|
||||||
@@ -57,7 +59,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
}
|
}
|
||||||
candidate := candidates[0]
|
candidate := candidates[0]
|
||||||
if candidate.ID != "quectel-0125-1-6" {
|
if candidate.ID != "usb-2c7c-0125-1-6" {
|
||||||
t.Fatalf("ID = %q", candidate.ID)
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Name != "ttyUSB2" {
|
if candidate.ATPort.Name != "ttyUSB2" {
|
||||||
@@ -101,6 +103,7 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
|||||||
)
|
)
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,6 +149,7 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, modem.usbName+":1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -194,6 +198,7 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, item.usbName+":1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -216,9 +221,11 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||||
|
// A plain USB serial adapter (FTDI) exposes ttyUSB but no QMI interface and
|
||||||
|
// is never bound to qmi_wwan, so it must not be treated as a modem.
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
||||||
@@ -235,6 +242,117 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryFindsNonQuectelVendorBoundToQMIWWAN(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// A Sierra EM7430 flashed to its QMI (rmnet0) composition: non-Quectel
|
||||||
|
// vendor, but its control interface is bound to qmi_wwan.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "idVendor"), "1199\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "idProduct"), "9077\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "manufacturer"), "Sierra Wireless, Incorporated\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "product"), "EM7430\n")
|
||||||
|
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||||
|
interfaceName := "1-3:1." + strconv.Itoa(number)
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
|
}
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-3:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-3:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.VendorID != "1199" || candidate.Product != "EM7430" {
|
||||||
|
t.Fatalf("candidate = %#v", candidate)
|
||||||
|
}
|
||||||
|
if candidate.ID != "usb-1199-9077-1-3" {
|
||||||
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
|
}
|
||||||
|
if candidate.ATPort.Role != PortRoleAT {
|
||||||
|
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
if candidate.DiscoveryIssue != "" {
|
||||||
|
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryMarksQMIModemWithoutATPort(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// QMI control interface is bound, but no ttyUSB/ttyACM node exists (for
|
||||||
|
// example the option/qcserial driver does not claim the serial interfaces).
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-7", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-7", "idProduct"), "0125\n")
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-7:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-7:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||||
|
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
if candidate.HasATPort() {
|
||||||
|
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryFindsHubAttachedQMIWWANDevice(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// Device behind a hub: the USB path "1-4.3.2" contains extra segments, and
|
||||||
|
// the qmi_wwan binding uses the same composite path before the colon.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idProduct"), "0125\n")
|
||||||
|
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||||
|
interfaceName := "1-4.3.2:1." + strconv.Itoa(number)
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
|
}
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-4.3.2:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-4.3.2:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.ATPort.Name != "ttyUSB2" {
|
||||||
|
t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
if !strings.Contains(candidate.ID, "1-4-3-2") {
|
||||||
|
t.Fatalf("ID = %q, want hub topology in discovery key", candidate.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
sysRoot := filepath.Join(root, "sys")
|
sysRoot := filepath.Join(root, "sys")
|
||||||
@@ -256,7 +374,7 @@ func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
|||||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||||
t.Fatalf("identity = %#v", candidate)
|
t.Fatalf("identity = %#v", candidate)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at1") || candidate.ATPort.Role != PortRoleAT {
|
||||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||||
}
|
}
|
||||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||||
@@ -290,6 +408,23 @@ func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectWWANATPortPrefersSecondaryATPort(t *testing.T) {
|
||||||
|
ports := []Port{
|
||||||
|
{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT},
|
||||||
|
{Name: "wwan0at1", InterfaceNumber: 1, Role: PortRoleAT},
|
||||||
|
}
|
||||||
|
if got := selectWWANATPort(ports); got.Name != "wwan0at1" {
|
||||||
|
t.Fatalf("selectWWANATPort = %#v, want wwan0at1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectWWANATPortFallsBackToPrimaryWhenOnlyAT0(t *testing.T) {
|
||||||
|
ports := []Port{{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT}}
|
||||||
|
if got := selectWWANATPort(ports); got.Name != "wwan0at0" {
|
||||||
|
t.Fatalf("selectWWANATPort = %#v, want wwan0at0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseWWANPortName(t *testing.T) {
|
func TestParseWWANPortName(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name, index, kind string
|
name, index, kind string
|
||||||
@@ -309,6 +444,115 @@ func TestParseWWANPortName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryFindsQuectelSerialModemWithoutQMIWWANBinding(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// A Quectel EC200A in its USB-serial composition (2c7c:6005) exposes ttyUSB
|
||||||
|
// control ports but no qmi_wwan-bound interface, so discovery must re-admit
|
||||||
|
// it by vendor instead of skipping it.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "6005\n")
|
||||||
|
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||||
|
interfaceName := "1-6:1." + strconv.Itoa(number)
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.VendorID != "2c7c" || candidate.ProductID != "6005" {
|
||||||
|
t.Fatalf("candidate = %#v", candidate)
|
||||||
|
}
|
||||||
|
if candidate.ID != "usb-2c7c-6005-1-6" {
|
||||||
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
|
}
|
||||||
|
if candidate.ATPort.Name != "ttyUSB2" || candidate.ATPort.Role != PortRoleAT {
|
||||||
|
t.Fatalf("AT port = %#v, want ttyUSB2 at role AT", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.DiscoveryIssue != "" {
|
||||||
|
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryMarksQuectelPeripheralWithoutATPort(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// A Quectel-branded peripheral exposing only a network interface (no
|
||||||
|
// ttyUSB/ttyACM, no qmi_wwan binding) cannot be driven yet, but vocat
|
||||||
|
// surfaces it with at_port_missing instead of silently dropping it so the
|
||||||
|
// operator sees the device is present and learns what to fix.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-8", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-8", "idProduct"), "6005\n")
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-8:1.0", "net", "enx001122334455"))
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||||
|
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
if candidate.HasATPort() {
|
||||||
|
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.NetworkInterface != "enx001122334455" {
|
||||||
|
t.Fatalf("network interface = %q", candidate.NetworkInterface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryMarksQuectelMBIMCompositionWithoutATPort(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// An EG25-G in MBIM composition (2c7c:0900) exposes cdc-wdm + net but no
|
||||||
|
// ttyUSB and has no qmi_wwan binding (cdc_mbim binds the control interface
|
||||||
|
// instead). vocat has no MBIM backend, so it must surface the device with
|
||||||
|
// at_port_missing rather than hiding it.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "0900\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-6", "product"), "EG25-G\n")
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "wwp0s20f0u6"))
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||||
|
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
if candidate.HasATPort() {
|
||||||
|
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.Product != "EG25-G" {
|
||||||
|
t.Fatalf("product = %q", candidate.Product)
|
||||||
|
}
|
||||||
|
// cdc-wdm0 sits under usbmisc/, which scanUSBInterface reports as a QMI
|
||||||
|
// control name; either way the device must appear present, not vanish.
|
||||||
|
if candidate.QMIControl == "" && candidate.NetworkInterface == "" {
|
||||||
|
t.Fatalf("candidate has neither QMI control nor net interface: %#v", candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustWrite(t *testing.T, path, value string) {
|
func mustWrite(t *testing.T, path, value string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mustMkdir(t, filepath.Dir(path))
|
mustMkdir(t, filepath.Dir(path))
|
||||||
@@ -323,3 +567,18 @@ func mustMkdir(t *testing.T, path string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mustBindQMIWWAN mimics the kernel's driver-binding directory entry: it adds
|
||||||
|
// interfaceName (e.g. "1-6:1.4") under /sys/bus/usb/drivers/qmi_wwan exactly
|
||||||
|
// like the real qmi_wwan driver directory does for a bound QMI interface.
|
||||||
|
func mustBindQMIWWAN(t *testing.T, sysRoot, interfaceName string) {
|
||||||
|
t.Helper()
|
||||||
|
driverDir := filepath.Join(sysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||||
|
if err := os.MkdirAll(driverDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(sysRoot, "bus", "usb", "devices", interfaceName)
|
||||||
|
if err := os.Symlink(target, filepath.Join(driverDir, interfaceName)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,14 +148,19 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
// Drain the transport before writing the command. Serial transports wait
|
||||||
session.poisonLocked()
|
// for any pending output here (a no-op after a synchronous command), while
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
// WWAN transports discard bytes left over from a previous command that
|
||||||
}
|
// timed out; without this, a late reply (e.g. a slow CGSN response) would
|
||||||
|
// be mis-parsed as this command's output.
|
||||||
if err := drainTransport(ctx, session.transport); err != nil {
|
if err := drainTransport(ctx, session.transport); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
|
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||||
|
session.poisonLocked()
|
||||||
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
|
}
|
||||||
return session.readFinalLocked(ctx, started, command, "", response)
|
return session.readFinalLocked(ctx, started, command, "", response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -453,6 +453,62 @@ func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainOrderTransport forwards to an inner Transport while recording
|
||||||
|
// transport-level events, so a test can assert the exact order of Drain and
|
||||||
|
// Write calls.
|
||||||
|
type drainOrderTransport struct {
|
||||||
|
inner Transport
|
||||||
|
events chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Write(payload []byte) (int, error) {
|
||||||
|
transport.events <- "write:" + string(payload)
|
||||||
|
return transport.inner.Write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Read(buffer []byte) (int, error) {
|
||||||
|
return transport.inner.Read(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Drain() error {
|
||||||
|
transport.events <- "drain"
|
||||||
|
return transport.inner.Drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) ResetInputBuffer() error {
|
||||||
|
return transport.inner.ResetInputBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) SetReadTimeout(timeout time.Duration) error {
|
||||||
|
return transport.inner.SetReadTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Close() error {
|
||||||
|
return transport.inner.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WWAN transports discard stale bytes left over from a timed-out command
|
||||||
|
// inside Drain, so the session must call it before writing the next command;
|
||||||
|
// otherwise a late reply (e.g. a slow CGSN response) would be mis-parsed as
|
||||||
|
// the new command's output.
|
||||||
|
func TestSessionDrainsBeforeWritingCommand(t *testing.T) {
|
||||||
|
inner := &transcriptTransport{steps: []transportStep{{
|
||||||
|
write: "AT+CSQ\r",
|
||||||
|
chunks: []string{"\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||||
|
}}}
|
||||||
|
events := make(chan string, 8)
|
||||||
|
session := newTestSession(t, &drainOrderTransport{inner: inner, events: events})
|
||||||
|
if _, err := session.Execute(context.Background(), "AT+CSQ"); err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if first := <-events; first != "drain" {
|
||||||
|
t.Fatalf("first transport event = %q, want drain before the command write", first)
|
||||||
|
}
|
||||||
|
if second := <-events; second != "write:AT+CSQ\r" {
|
||||||
|
t.Fatalf("second transport event = %q, want the command write", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestSession(t *testing.T, transport Transport) *Session {
|
func newTestSession(t *testing.T, transport Transport) *Session {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
session, err := NewSession(transport, SessionOptions{
|
session, err := NewSession(transport, SessionOptions{
|
||||||
|
|||||||
@@ -108,9 +108,28 @@ func (transport *nativeWWANATTransport) Drain() error {
|
|||||||
return io.ErrClosedPipe
|
return io.ErrClosedPipe
|
||||||
}
|
}
|
||||||
// WWAN character-device writes are handed to the modem synchronously and
|
// WWAN character-device writes are handed to the modem synchronously and
|
||||||
// have no termios output queue to drain.
|
// have no termios output queue to drain. A previous command that timed out
|
||||||
|
// can leave late bytes in the input buffer (e.g. a slow CGSN reply that
|
||||||
|
// arrives after the command deadline); discard them here so the next
|
||||||
|
// command starts from a clean stream instead of mis-parsing stale output.
|
||||||
|
buffer := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||||
|
ready, err := unix.Poll(fds, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if _, err := unix.Read(transport.fd, buffer); err != nil {
|
||||||
|
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
||||||
transport.mu.RLock()
|
transport.mu.RLock()
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package modem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNativeWWANATTransportDrainDiscardsPendingBytes verifies Drain discards
|
||||||
|
// every byte already buffered on the transport. A command that timed out (e.g.
|
||||||
|
// AT+CGSN on an MHI modem that never answers OK) can leave its late reply in
|
||||||
|
// the input buffer; the next command's Drain must clear it, however much data
|
||||||
|
// is pending, before the session writes the new command.
|
||||||
|
func TestNativeWWANATTransportDrainDiscardsPendingBytes(t *testing.T) {
|
||||||
|
readFD, writeFD := socketpair(t)
|
||||||
|
defer unix.Close(writeFD)
|
||||||
|
|
||||||
|
// More than one 4096-byte Drain read: a slow CGSN reply (echo + IMEI +
|
||||||
|
// trailing CRLF) can exceed a single buffer.
|
||||||
|
payload := make([]byte, 12000)
|
||||||
|
for index := range payload {
|
||||||
|
payload[index] = byte('A' + index%26)
|
||||||
|
}
|
||||||
|
payload = append(payload, []byte("\r\n+CGSN: 357091089453326\r\n")...)
|
||||||
|
if _, err := unix.Write(writeFD, payload); err != nil {
|
||||||
|
t.Fatalf("seed stale bytes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||||
|
if err := transport.Drain(); err != nil {
|
||||||
|
t.Fatalf("Drain: %v", err)
|
||||||
|
}
|
||||||
|
assertNoPendingBytes(t, readFD, "after Drain")
|
||||||
|
|
||||||
|
// Draining a clean transport is a fast no-op that must not block or error.
|
||||||
|
if err := transport.Drain(); err != nil {
|
||||||
|
t.Fatalf("second Drain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNativeWWANATTransportDrainRejectsClosedTransport covers the guard that
|
||||||
|
// keeps a poisoned session from draining a wedged, already-closed fd.
|
||||||
|
func TestNativeWWANATTransportDrainRejectsClosedTransport(t *testing.T) {
|
||||||
|
readFD, writeFD := socketpair(t)
|
||||||
|
defer unix.Close(writeFD)
|
||||||
|
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||||
|
if err := transport.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
if err := transport.Drain(); !errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
t.Fatalf("Drain after Close = %v, want ErrClosedPipe", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func socketpair(t *testing.T) (int, int) {
|
||||||
|
t.Helper()
|
||||||
|
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return fds[0], fds[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoPendingBytes(t *testing.T, fd int, context string) {
|
||||||
|
t.Helper()
|
||||||
|
fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
||||||
|
ready, err := unix.Poll(fds, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("poll %s: %v", context, err)
|
||||||
|
}
|
||||||
|
if ready != 0 {
|
||||||
|
t.Fatalf("%s: fd still readable", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
-1
@@ -3,6 +3,8 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -17,18 +19,44 @@ type ProbeResult struct {
|
|||||||
Reachable bool `json:"reachable"`
|
Reachable bool `json:"reachable"`
|
||||||
HandshakeOK bool `json:"handshake_ok"`
|
HandshakeOK bool `json:"handshake_ok"`
|
||||||
UDPAssociateOK bool `json:"udp_associate_ok"`
|
UDPAssociateOK bool `json:"udp_associate_ok"`
|
||||||
|
UDPExchangeOK bool `json:"udp_exchange_ok"`
|
||||||
AuthMethod string `json:"auth_method,omitempty"`
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
RelayAddr string `json:"relay_addr,omitempty"`
|
RelayAddr string `json:"relay_addr,omitempty"`
|
||||||
|
DNSServer string `json:"dns_server,omitempty"`
|
||||||
|
DNSName string `json:"dns_name,omitempty"`
|
||||||
|
DNSRCode int `json:"dns_rcode,omitempty"`
|
||||||
|
RoundTripMS int64 `json:"round_trip_ms,omitempty"`
|
||||||
Diagnosis string `json:"diagnosis,omitempty"`
|
Diagnosis string `json:"diagnosis,omitempty"`
|
||||||
Hint string `json:"hint,omitempty"`
|
Hint string `json:"hint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultProbeDNSServer = "1.1.1.1:53"
|
||||||
|
defaultProbeDNSName = "example.com"
|
||||||
|
)
|
||||||
|
|
||||||
func ProbeSOCKS5(
|
func ProbeSOCKS5(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
address string,
|
address string,
|
||||||
username string,
|
username string,
|
||||||
password string,
|
password string,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
|
) (ProbeResult, error) {
|
||||||
|
return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real
|
||||||
|
// UDP DNS round trip through the returned relay. Keeping the target injectable
|
||||||
|
// makes the negative paths deterministic in tests without weakening the
|
||||||
|
// production probe.
|
||||||
|
func probeSOCKS5(
|
||||||
|
ctx context.Context,
|
||||||
|
address string,
|
||||||
|
username string,
|
||||||
|
password string,
|
||||||
|
timeout time.Duration,
|
||||||
|
dnsServer string,
|
||||||
|
dnsName string,
|
||||||
) (ProbeResult, error) {
|
) (ProbeResult, error) {
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||||
@@ -122,11 +150,228 @@ func ProbeSOCKS5(
|
|||||||
port := int(portBytes[0])<<8 | int(portBytes[1])
|
port := int(portBytes[0])<<8 | int(portBytes[1])
|
||||||
result.UDPAssociateOK = true
|
result.UDPAssociateOK = true
|
||||||
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||||
|
result.DNSServer = dnsServer
|
||||||
|
result.DNSName = dnsName
|
||||||
|
|
||||||
|
if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil {
|
||||||
|
if result.Diagnosis == "" {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
}
|
||||||
|
if result.Hint == "" {
|
||||||
|
result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。")
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
result.Diagnosis = "ready"
|
result.Diagnosis = "ready"
|
||||||
result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。")
|
result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。")
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func probeUDPExchange(
|
||||||
|
ctx context.Context,
|
||||||
|
control net.Conn,
|
||||||
|
result *ProbeResult,
|
||||||
|
relayHost string,
|
||||||
|
relayPort int,
|
||||||
|
dnsServer string,
|
||||||
|
dnsName string,
|
||||||
|
timeout time.Duration,
|
||||||
|
) error {
|
||||||
|
if result == nil {
|
||||||
|
return errors.New("proxy: probe result is nil")
|
||||||
|
}
|
||||||
|
dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer))
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_target"
|
||||||
|
return fmt.Errorf("proxy: resolve UDP probe target: %w", err)
|
||||||
|
}
|
||||||
|
relayHost = strings.TrimSpace(relayHost)
|
||||||
|
if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() {
|
||||||
|
remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String())
|
||||||
|
if splitErr != nil {
|
||||||
|
result.Diagnosis = "invalid_udp_relay"
|
||||||
|
return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr)
|
||||||
|
}
|
||||||
|
relayHost = remoteHost
|
||||||
|
}
|
||||||
|
relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort)))
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_udp_relay"
|
||||||
|
return fmt.Errorf("proxy: resolve UDP relay: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localNetwork := "udp4"
|
||||||
|
if relayAddress.IP != nil && relayAddress.IP.To4() == nil {
|
||||||
|
localNetwork = "udp6"
|
||||||
|
}
|
||||||
|
udpConnection, err := net.ListenUDP(localNetwork, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_socket_failed"
|
||||||
|
return fmt.Errorf("proxy: open UDP probe socket: %w", err)
|
||||||
|
}
|
||||||
|
defer udpConnection.Close()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||||
|
deadline = contextDeadline
|
||||||
|
}
|
||||||
|
if err := udpConnection.SetDeadline(deadline); err != nil {
|
||||||
|
return fmt.Errorf("proxy: set UDP probe deadline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query, queryID, err := buildDNSQuery(dnsName)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_name"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
datagram, err := buildSOCKSUDPDatagram(dnsAddress, query)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_target"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
startedAt := time.Now()
|
||||||
|
if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil {
|
||||||
|
result.Diagnosis = "udp_send_failed"
|
||||||
|
return fmt.Errorf("proxy: send UDP DNS probe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBuffer := make([]byte, 64*1024)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err)
|
||||||
|
}
|
||||||
|
count, sender, err := udpConnection.ReadFromUDP(responseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err)
|
||||||
|
}
|
||||||
|
if !sameUDPAddress(sender, relayAddress) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, err := parseSOCKSUDPDatagram(responseBuffer[:count])
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_invalid_response"
|
||||||
|
return fmt.Errorf("proxy: parse UDP relay response: %w", err)
|
||||||
|
}
|
||||||
|
rcode, err := validateDNSResponse(payload, queryID)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "dns_invalid_response"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.UDPExchangeOK = true
|
||||||
|
result.DNSRCode = rcode
|
||||||
|
result.RoundTripMS = time.Since(startedAt).Milliseconds()
|
||||||
|
if result.RoundTripMS < 1 {
|
||||||
|
result.RoundTripMS = 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDNSQuery(name string) ([]byte, uint16, error) {
|
||||||
|
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||||
|
if name == "" || len(name) > 253 {
|
||||||
|
return nil, 0, errors.New("proxy: UDP probe DNS name is invalid")
|
||||||
|
}
|
||||||
|
var idBytes [2]byte
|
||||||
|
if _, err := rand.Read(idBytes[:]); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err)
|
||||||
|
}
|
||||||
|
queryID := binary.BigEndian.Uint16(idBytes[:])
|
||||||
|
query := make([]byte, 12, 12+len(name)+6)
|
||||||
|
binary.BigEndian.PutUint16(query[0:2], queryID)
|
||||||
|
binary.BigEndian.PutUint16(query[2:4], 0x0100)
|
||||||
|
binary.BigEndian.PutUint16(query[4:6], 1)
|
||||||
|
for _, label := range strings.Split(name, ".") {
|
||||||
|
if label == "" || len(label) > 63 {
|
||||||
|
return nil, 0, errors.New("proxy: UDP probe DNS label is invalid")
|
||||||
|
}
|
||||||
|
query = append(query, byte(len(label)))
|
||||||
|
query = append(query, label...)
|
||||||
|
}
|
||||||
|
query = append(query, 0, 0, 1, 0, 1)
|
||||||
|
return query, queryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||||
|
if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 {
|
||||||
|
return nil, errors.New("proxy: UDP target is invalid")
|
||||||
|
}
|
||||||
|
packet := []byte{0, 0, 0}
|
||||||
|
if ipv4 := target.IP.To4(); ipv4 != nil {
|
||||||
|
packet = append(packet, 1)
|
||||||
|
packet = append(packet, ipv4...)
|
||||||
|
} else if ipv6 := target.IP.To16(); ipv6 != nil {
|
||||||
|
packet = append(packet, 4)
|
||||||
|
packet = append(packet, ipv6...)
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("proxy: UDP target address family is invalid")
|
||||||
|
}
|
||||||
|
packet = append(packet, byte(target.Port>>8), byte(target.Port))
|
||||||
|
packet = append(packet, payload...)
|
||||||
|
return packet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) {
|
||||||
|
if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 {
|
||||||
|
return nil, errors.New("invalid SOCKS5 UDP header")
|
||||||
|
}
|
||||||
|
if packet[2] != 0 {
|
||||||
|
return nil, errors.New("fragmented SOCKS5 UDP response is unsupported")
|
||||||
|
}
|
||||||
|
offset := 4
|
||||||
|
switch packet[3] {
|
||||||
|
case 1:
|
||||||
|
offset += net.IPv4len
|
||||||
|
case 3:
|
||||||
|
if len(packet) <= offset {
|
||||||
|
return nil, errors.New("truncated SOCKS5 UDP domain")
|
||||||
|
}
|
||||||
|
offset += 1 + int(packet[offset])
|
||||||
|
case 4:
|
||||||
|
offset += net.IPv6len
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported SOCKS5 UDP address type")
|
||||||
|
}
|
||||||
|
if offset+2 > len(packet) {
|
||||||
|
return nil, errors.New("truncated SOCKS5 UDP endpoint")
|
||||||
|
}
|
||||||
|
offset += 2
|
||||||
|
if offset >= len(packet) {
|
||||||
|
return nil, errors.New("empty SOCKS5 UDP payload")
|
||||||
|
}
|
||||||
|
return packet[offset:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDNSResponse(payload []byte, queryID uint16) (int, error) {
|
||||||
|
if len(payload) < 12 {
|
||||||
|
return 0, errors.New("proxy: DNS response is truncated")
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint16(payload[0:2]) != queryID {
|
||||||
|
return 0, errors.New("proxy: DNS response ID does not match")
|
||||||
|
}
|
||||||
|
flags := binary.BigEndian.Uint16(payload[2:4])
|
||||||
|
if flags&0x8000 == 0 {
|
||||||
|
return 0, errors.New("proxy: DNS response is not a response")
|
||||||
|
}
|
||||||
|
rcode := int(flags & 0x000f)
|
||||||
|
if rcode != 0 {
|
||||||
|
return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode)
|
||||||
|
}
|
||||||
|
return rcode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameUDPAddress(left, right *net.UDPAddr) bool {
|
||||||
|
if left == nil || right == nil || left.Port != right.Port {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left.IP == nil || right.IP == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return left.IP.Equal(right.IP)
|
||||||
|
}
|
||||||
|
|
||||||
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
||||||
switch addressType {
|
switch addressType {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) {
|
||||||
|
address, stop := startProbeSOCKS5Server(t, false)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
result, err := probeSOCKS5(
|
||||||
|
context.Background(),
|
||||||
|
address,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
250*time.Millisecond,
|
||||||
|
"192.0.2.53:53",
|
||||||
|
"example.test",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data")
|
||||||
|
}
|
||||||
|
if !result.UDPAssociateOK {
|
||||||
|
t.Fatal("UDP ASSOCIATE should have succeeded")
|
||||||
|
}
|
||||||
|
if result.UDPExchangeOK {
|
||||||
|
t.Fatal("UDP exchange should not be reported as successful")
|
||||||
|
}
|
||||||
|
if result.Diagnosis != "udp_no_roundtrip" {
|
||||||
|
t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) {
|
||||||
|
address, stop := startProbeSOCKS5Server(t, true)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
result, err := probeSOCKS5(
|
||||||
|
context.Background(),
|
||||||
|
address,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
time.Second,
|
||||||
|
"192.0.2.53:53",
|
||||||
|
"example.test",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Probe returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK {
|
||||||
|
t.Fatalf("Probe evidence incomplete: %+v", result)
|
||||||
|
}
|
||||||
|
if result.Diagnosis != "ready" {
|
||||||
|
t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis)
|
||||||
|
}
|
||||||
|
if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" {
|
||||||
|
t.Fatalf("Unexpected DNS evidence: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListenUDP: %v", err)
|
||||||
|
}
|
||||||
|
tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
udpConnection.Close()
|
||||||
|
t.Fatalf("Listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if echoDNS {
|
||||||
|
go func() {
|
||||||
|
buffer := make([]byte, 2048)
|
||||||
|
count, sender, readErr := udpConnection.ReadFromUDP(buffer)
|
||||||
|
if readErr != nil || count < 22 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The test target is IPv4, so the SOCKS5 UDP header is ten bytes.
|
||||||
|
buffer[12] = 0x81
|
||||||
|
buffer[13] = 0x80
|
||||||
|
_, _ = udpConnection.WriteToUDP(buffer[:count], sender)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
connection, acceptErr := tcpListener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
greeting := make([]byte, 3)
|
||||||
|
if _, readErr := io.ReadFull(connection, greeting); readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
associate := make([]byte, 10)
|
||||||
|
if _, readErr := io.ReadFull(connection, associate); readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)}
|
||||||
|
if _, writeErr := connection.Write(response); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, connection)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return tcpListener.Addr().String(), func() {
|
||||||
|
_ = tcpListener.Close()
|
||||||
|
_ = udpConnection.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/modem"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -22,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{
|
||||||
@@ -39,8 +78,67 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The AT terminal must present ERROR / +CME ERROR as a normal response, not as
|
||||||
|
// a 502. Before the CommandError branch was restored, every unsupported or
|
||||||
|
// SIM-less command was folded into "the device operation failed", hiding the
|
||||||
|
// real reason from the user.
|
||||||
|
func TestHandleATSurfacesCommandErrorAsResponse(t *testing.T) {
|
||||||
|
controller := fakeDeviceController{
|
||||||
|
entry: device.Device{ID: "dev1"},
|
||||||
|
atHandler: func(command string) (modem.Response, error) {
|
||||||
|
return modem.Response{}, &modem.CommandError{
|
||||||
|
Command: command,
|
||||||
|
Final: "+CME ERROR: 10",
|
||||||
|
Lines: []string{"+CME ERROR: 10"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/devices/dev1/actions/at",
|
||||||
|
strings.NewReader(`{"cmd":"AT+CPIN?","timeout_ms":5000}`),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !server.handleAT(recorder, request, "dev1") {
|
||||||
|
t.Fatal("handleAT returned false")
|
||||||
|
}
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
data := decodeData(t, recorder)
|
||||||
|
response, _ := data["response"].(string)
|
||||||
|
if !strings.Contains(response, "+CME ERROR: 10") {
|
||||||
|
t.Fatalf("response = %q, want +CME ERROR text", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleATMapsNonCommandErrorTo502(t *testing.T) {
|
||||||
|
controller := fakeDeviceController{
|
||||||
|
entry: device.Device{ID: "dev1"},
|
||||||
|
atErr: errors.New("transport wedged"),
|
||||||
|
}
|
||||||
|
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/devices/dev1/actions/at",
|
||||||
|
strings.NewReader(`{"cmd":"AT+CSQ"}`),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !server.handleAT(recorder, request, "dev1") {
|
||||||
|
t.Fatal("handleAT returned false")
|
||||||
|
}
|
||||||
|
if recorder.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want 502", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
|||||||
}, "\n"),
|
}, "\n"),
|
||||||
Time: run.FinishedAt, Task: task, Run: run,
|
Time: run.FinishedAt, Task: task, Run: run,
|
||||||
}
|
}
|
||||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||||
continue
|
continue
|
||||||
@@ -91,6 +91,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
|||||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||||
|
case "lark":
|
||||||
|
return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message))
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-12
@@ -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
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1060,26 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
response, err := s.devices.ExecuteAT(ctx, id, command)
|
response, err := s.devices.ExecuteAT(ctx, id, command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var commandErr *modem.CommandError
|
||||||
|
if errors.As(err, &commandErr) {
|
||||||
|
// The modem answered with ERROR / +CME ERROR. An AT terminal must
|
||||||
|
// surface that text (including the CME detail) as a normal response;
|
||||||
|
// folding it into a 502 hides the real reason from the user.
|
||||||
|
text := strings.Join(commandErr.Lines, "\n")
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += commandErr.Final
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"data": map[string]any{
|
||||||
|
"response": text,
|
||||||
|
"final": commandErr.Final,
|
||||||
|
"duration_ms": 0,
|
||||||
|
"urcs": []string{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
s.writeDeviceError(w, err)
|
s.writeDeviceError(w, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1080,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")
|
||||||
@@ -1088,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"`,
|
||||||
@@ -1118,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
|
||||||
}
|
}
|
||||||
@@ -1132,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
|
||||||
@@ -1616,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"},
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.store.Ready(ctx); err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMetrics exposes only process-level, non-identifying Prometheus data.
|
||||||
|
// Device IDs, SIM identities, phone numbers and proxy information never enter
|
||||||
|
// this unauthenticated endpoint.
|
||||||
|
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ready := 0
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||||
|
if s.store.Ready(ctx) == nil {
|
||||||
|
ready = 1
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
fmt.Fprint(w, "# HELP vocat_up Whether the process is running.\n# TYPE vocat_up gauge\nvocat_up 1\n")
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_ready Whether the database is ready.\n# TYPE vocat_ready gauge\nvocat_ready %d\n", ready)
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_uptime_seconds Process uptime.\n# TYPE vocat_uptime_seconds counter\nvocat_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_go_goroutines Current Go goroutines.\n# TYPE vocat_go_goroutines gauge\nvocat_go_goroutines %d\n", runtime.NumGoroutine())
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOperationalHealthEndpointsAreAnonymousAndNonIdentifying(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
contentType string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{path: "/healthz", contentType: "application/json", contains: `"status":"ok"`},
|
||||||
|
{path: "/readyz", contentType: "application/json", contains: `"status":"ready"`},
|
||||||
|
{path: "/metrics", contentType: "text/plain", contains: "vocat_ready 1"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.path, func(t *testing.T) {
|
||||||
|
response, err := app.client.Get(app.server.URL + test.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Header.Get("Content-Type"), test.contentType) {
|
||||||
|
t.Fatalf("Content-Type = %q", response.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), test.contains) {
|
||||||
|
t.Fatalf("body = %q, want %q", body, test.contains)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"imsi", "iccid", "msisdn", "proxy", "device_id"} {
|
||||||
|
if strings.Contains(strings.ToLower(string(body)), forbidden) {
|
||||||
|
t.Fatalf("body exposes forbidden label %q: %s", forbidden, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationalHealthEndpointsRejectPOST(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
for _, path := range []string{"/healthz", "/readyz", "/metrics"} {
|
||||||
|
request, err := http.NewRequest(http.MethodPost, app.server.URL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response, err := app.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("%s status = %d, want %d", path, response.StatusCode, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxLarkPayloadBytes = 20 << 10
|
||||||
|
|
||||||
|
var larkTemplateVariableNames = []string{
|
||||||
|
"event",
|
||||||
|
"title",
|
||||||
|
"message",
|
||||||
|
"timestamp",
|
||||||
|
"content",
|
||||||
|
"number",
|
||||||
|
"device_id",
|
||||||
|
"device_name",
|
||||||
|
"device_label",
|
||||||
|
"time",
|
||||||
|
}
|
||||||
|
|
||||||
|
var larkTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^{}]*\}\}`)
|
||||||
|
|
||||||
|
var larkWebhookHosts = map[string]struct{}{
|
||||||
|
"open.feishu.cn": {},
|
||||||
|
"open.larksuite.com": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type larkTemplateValues map[string]string
|
||||||
|
|
||||||
|
func renderLarkPayload(template string, values larkTemplateValues) ([]byte, error) {
|
||||||
|
encodedValues := make(map[string]string, len(larkTemplateVariableNames))
|
||||||
|
for _, name := range larkTemplateVariableNames {
|
||||||
|
encoded, err := json.Marshal(values[name])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode Lark template value %q: %w", name, err)
|
||||||
|
}
|
||||||
|
encodedValues[name] = string(encoded)
|
||||||
|
}
|
||||||
|
unsupported := false
|
||||||
|
rendered := larkTemplatePlaceholderPattern.ReplaceAllStringFunc(template, func(placeholder string) string {
|
||||||
|
name := placeholder[2 : len(placeholder)-2]
|
||||||
|
encoded, ok := encodedValues[name]
|
||||||
|
if !ok {
|
||||||
|
unsupported = true
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
})
|
||||||
|
remainder := larkTemplatePlaceholderPattern.ReplaceAllString(template, "")
|
||||||
|
if unsupported || strings.Contains(remainder, "{{") {
|
||||||
|
return nil, errors.New("lark.payload_template contains an unsupported variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(rendered), &payload); err != nil || len(payload) == 0 {
|
||||||
|
return nil, errors.New("lark.payload_template must render to a non-empty JSON object")
|
||||||
|
}
|
||||||
|
if len(rendered) > maxLarkPayloadBytes {
|
||||||
|
return nil, errors.New("lark.payload_template renders beyond the 20 KB Lark limit")
|
||||||
|
}
|
||||||
|
return []byte(rendered), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkSignature(timestamp int64, secret string) string {
|
||||||
|
key := strconv.FormatInt(timestamp, 10) + "\n" + secret
|
||||||
|
signature := hmac.New(sha256.New, []byte(key))
|
||||||
|
return base64.StdEncoding.EncodeToString(signature.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func signLarkPayload(payload []byte, secret string, now time.Time) ([]byte, error) {
|
||||||
|
if secret == "" {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
var document map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &document); err != nil || len(document) == 0 {
|
||||||
|
return nil, errors.New("lark payload must be a non-empty JSON object")
|
||||||
|
}
|
||||||
|
timestamp := now.Unix()
|
||||||
|
document["timestamp"], _ = json.Marshal(strconv.FormatInt(timestamp, 10))
|
||||||
|
document["sign"], _ = json.Marshal(larkSignature(timestamp, secret))
|
||||||
|
signed, err := json.Marshal(document)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode signed Lark payload: %w", err)
|
||||||
|
}
|
||||||
|
if len(signed) > maxLarkPayloadBytes {
|
||||||
|
return nil, errors.New("lark payload exceeds the 20 KB Lark limit after signing")
|
||||||
|
}
|
||||||
|
return signed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLarkResponse(status int, body []byte) error {
|
||||||
|
var result struct {
|
||||||
|
Code *int `json:"code"`
|
||||||
|
StatusCode *int `json:"StatusCode"`
|
||||||
|
}
|
||||||
|
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil {
|
||||||
|
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||||
|
}
|
||||||
|
if result.Code != nil {
|
||||||
|
if *result.Code == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||||
|
}
|
||||||
|
if result.StatusCode == nil || *result.StatusCode != 0 {
|
||||||
|
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
||||||
|
parsed, err := parseOutboundURL(raw, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok {
|
||||||
|
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
|
||||||
|
}
|
||||||
|
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||||
|
return nil, errors.New("Lark group bot webhook must use the default HTTPS port")
|
||||||
|
}
|
||||||
|
const prefix = "/open-apis/bot/v2/hook/"
|
||||||
|
token := strings.TrimPrefix(parsed.Path, prefix)
|
||||||
|
if token == parsed.Path || token == "" || strings.Contains(token, "/") || parsed.RawPath != "" ||
|
||||||
|
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
||||||
|
return nil, errors.New("Lark group bot webhook path is invalid")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||||
|
parsed, err := parseLarkWebhookURL(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkTestValues(now time.Time) larkTemplateValues {
|
||||||
|
return larkTemplateValues{
|
||||||
|
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||||
|
"timestamp": now.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkSMSValues(message smsNotification) larkTemplateValues {
|
||||||
|
return larkTemplateValues{
|
||||||
|
"event": "sms.received",
|
||||||
|
"title": "收到新短信",
|
||||||
|
"message": message.Text(),
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": message.Content,
|
||||||
|
"number": message.Number,
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValues {
|
||||||
|
return larkTemplateValues{
|
||||||
|
"event": "automatic_task.completed",
|
||||||
|
"title": message.Title,
|
||||||
|
"message": message.Text,
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": "",
|
||||||
|
"number": "",
|
||||||
|
"device_id": "",
|
||||||
|
"device_name": "",
|
||||||
|
"device_label": "",
|
||||||
|
"time": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLarkNotificationConfig(config map[string]any) error {
|
||||||
|
if configString(config, "url") == "" {
|
||||||
|
return errors.New("lark.url is required")
|
||||||
|
}
|
||||||
|
template := configString(config, "payload_template")
|
||||||
|
if template == "" {
|
||||||
|
return errors.New("lark.payload_template is required")
|
||||||
|
}
|
||||||
|
if signingEnabled, _ := config["signing_enabled"].(bool); signingEnabled {
|
||||||
|
secret := configString(config, "secret")
|
||||||
|
if secret == "" {
|
||||||
|
return errors.New("lark.secret is required when signing is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkSigningSecret(config map[string]any) string {
|
||||||
|
enabled, _ := config["signing_enabled"].(bool)
|
||||||
|
if !enabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return configString(config, "secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
|
||||||
|
if err := validateLarkNotificationConfig(config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload, err = signLarkPayload(payload, larkSigningSecret(config), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, err := validateLarkWebhookURL(ctx, configString(config, "url"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return postLarkNotification(ctx, client, parsed.String(), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postLarkNotification(ctx context.Context, client *http.Client, endpoint string, payload []byte) error {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create Lark notification request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
request.Header.Set("User-Agent", "vocat-lark-notification/1")
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||||
|
closeErr := response.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("read Lark response: %w", readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("close Lark response: %w", closeErr)
|
||||||
|
}
|
||||||
|
if err := validateLarkResponse(response.StatusCode, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeLarkRequestError(err error) error {
|
||||||
|
var requestErr *url.Error
|
||||||
|
if errors.As(err, &requestErr) && requestErr.Err != nil {
|
||||||
|
return requestErr.Err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendLarkNotificationTest(ctx context.Context, config map[string]any) error {
|
||||||
|
return sendLarkNotification(ctx, config, larkTestValues(time.Now()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderLarkPayloadEscapesTemplateValues(t *testing.T) {
|
||||||
|
payload, err := renderLarkPayload(
|
||||||
|
`{"msg_type":"text","content":{"text":{{message}},"number":{{number}}}}`,
|
||||||
|
larkTemplateValues{
|
||||||
|
"message": "quote: \"\nline",
|
||||||
|
"number": "+447386",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := string(payload), `{"msg_type":"text","content":{"text":"quote: \"\nline","number":"+447386"}}`; got != want {
|
||||||
|
t.Fatalf("payload = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderLarkPayloadDoesNotInterpretPlaceholdersInsideValues(t *testing.T) {
|
||||||
|
payload, err := renderLarkPayload(
|
||||||
|
`{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
larkTemplateValues{"message": "keep {{timestamp}} literally", "timestamp": "changed"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := string(payload), `{"msg_type":"text","content":{"text":"keep {{timestamp}} literally"}}`; got != want {
|
||||||
|
t.Fatalf("payload = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderLarkPayloadRejectsInvalidTemplate(t *testing.T) {
|
||||||
|
for _, template := range []string{
|
||||||
|
`{"text":{{unknown}}}`,
|
||||||
|
`[]`,
|
||||||
|
`{"msg_type":"text"`,
|
||||||
|
`{"text":"` + strings.Repeat("x", maxLarkPayloadBytes) + `"}`,
|
||||||
|
} {
|
||||||
|
t.Run(template[:min(len(template), 40)], func(t *testing.T) {
|
||||||
|
if _, err := renderLarkPayload(template, larkTemplateValues{}); err == nil {
|
||||||
|
t.Fatalf("template was accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignLarkPayload(t *testing.T) {
|
||||||
|
const timestamp = int64(1_599_360_473)
|
||||||
|
if got, want := larkSignature(timestamp, "demo"), "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8="; got != want {
|
||||||
|
t.Fatalf("signature = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
||||||
|
signed, err := signLarkPayload(unsigned, "demo", time.Unix(timestamp, 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(signed, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["timestamp"] != "1599360473" || payload["sign"] != "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8=" {
|
||||||
|
t.Fatalf("signed payload = %#v", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
untouched, err := signLarkPayload(unsigned, "", time.Unix(timestamp, 0))
|
||||||
|
if err != nil || string(untouched) != string(unsigned) {
|
||||||
|
t.Fatalf("unsigned payload = %s, err = %v", untouched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLarkResponse(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"code":0,"msg":"success"}`,
|
||||||
|
`{"StatusCode":0,"StatusMessage":"success"}`,
|
||||||
|
} {
|
||||||
|
if err := validateLarkResponse(http.StatusOK, []byte(body)); err != nil {
|
||||||
|
t.Fatalf("successful response %s = %v", body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, response := range []struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{http.StatusBadGateway, `{"code":0}`},
|
||||||
|
{http.StatusOK, `{"code":19021,"msg":"sign match fail or timestamp is not within one hour from current time","StatusCode":0}`},
|
||||||
|
{http.StatusOK, `{"StatusCode":19021,"StatusMessage":"sign error"}`},
|
||||||
|
{http.StatusOK, `{}`},
|
||||||
|
{http.StatusOK, `not-json`},
|
||||||
|
} {
|
||||||
|
if err := validateLarkResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
|
||||||
|
t.Fatalf("validateLarkResponse(%d, %s) = %v", response.status, response.body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostLarkNotificationSendsJSONPayload(t *testing.T) {
|
||||||
|
payload := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf-8" {
|
||||||
|
t.Errorf("Content-Type = %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("User-Agent"); got != "vocat-lark-notification/1" {
|
||||||
|
t.Errorf("User-Agent = %q", got)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read body: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != string(payload) {
|
||||||
|
t.Errorf("body = %s, want %s", body, payload)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(provider.Close)
|
||||||
|
|
||||||
|
if err := postLarkNotification(context.Background(), provider.Client(), provider.URL, payload); err != nil {
|
||||||
|
t.Fatalf("postLarkNotification() = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLarkWebhookURL(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token",
|
||||||
|
"https://open.larksuite.com/open-apis/bot/v2/hook/lark-token",
|
||||||
|
"https://open.larksuite.com:443/open-apis/bot/v2/hook/lark-token",
|
||||||
|
} {
|
||||||
|
if _, err := parseLarkWebhookURL(raw); err != nil {
|
||||||
|
t.Errorf("parseLarkWebhookURL(%q) = %v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||||
|
"https://example.com/open-apis/bot/v2/hook/token",
|
||||||
|
"https://open.larksuite.com/open-apis/bot/hook/token",
|
||||||
|
"https://open.larksuite.com/open-apis/bot/v2/hook/",
|
||||||
|
"https://open.larksuite.com/open-apis/bot/v2/hook/token/extra",
|
||||||
|
"https://open.larksuite.com/open-apis/bot/v2/hook/token?query=1",
|
||||||
|
} {
|
||||||
|
if _, err := parseLarkWebhookURL(raw); err == nil {
|
||||||
|
t.Errorf("parseLarkWebhookURL(%q) accepted an invalid group bot webhook", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLarkNotificationConfig(t *testing.T) {
|
||||||
|
valid := map[string]any{
|
||||||
|
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||||
|
"signing_enabled": true,
|
||||||
|
"secret": "demo",
|
||||||
|
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
}
|
||||||
|
if err := validateLarkNotificationConfig(valid); err != nil {
|
||||||
|
t.Fatalf("valid config = %v", err)
|
||||||
|
}
|
||||||
|
unsigned := map[string]any{
|
||||||
|
"url": valid["url"],
|
||||||
|
"signing_enabled": false,
|
||||||
|
"payload_template": valid["payload_template"],
|
||||||
|
}
|
||||||
|
if err := validateLarkNotificationConfig(unsigned); err != nil {
|
||||||
|
t.Fatalf("unsigned config = %v", err)
|
||||||
|
}
|
||||||
|
if secret := larkSigningSecret(map[string]any{"signing_enabled": false, "secret": "demo"}); secret != "" {
|
||||||
|
t.Fatalf("disabled signing secret = %q", secret)
|
||||||
|
}
|
||||||
|
if secret := larkSigningSecret(valid); secret != "demo" {
|
||||||
|
t.Fatalf("enabled signing secret = %q", secret)
|
||||||
|
}
|
||||||
|
for _, config := range []map[string]any{
|
||||||
|
{"payload_template": valid["payload_template"]},
|
||||||
|
{"url": valid["url"]},
|
||||||
|
{"url": valid["url"], "signing_enabled": true, "payload_template": valid["payload_template"]},
|
||||||
|
} {
|
||||||
|
if err := validateLarkNotificationConfig(config); err == nil {
|
||||||
|
t.Fatalf("invalid config was accepted: %#v", config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeLarkRequestErrorRemovesWebhookURL(t *testing.T) {
|
||||||
|
const webhookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/sensitive-token"
|
||||||
|
err := sanitizeLarkRequestError(&url.Error{Op: "Post", URL: webhookURL, Err: errors.New("dial failed")})
|
||||||
|
if strings.Contains(err.Error(), "sensitive-token") || err.Error() != "dial failed" {
|
||||||
|
t.Fatalf("sanitized error = %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -395,8 +395,8 @@ func (s *Server) saveAndProbeUpstream(
|
|||||||
)
|
)
|
||||||
probeResponse := probeMap(probe, probeErr)
|
probeResponse := probeMap(probe, probeErr)
|
||||||
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
||||||
if probeErr == nil && probe.UDPAssociateOK {
|
if probeErr == nil && probe.UDPExchangeOK {
|
||||||
message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。")
|
message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -425,8 +425,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id
|
|||||||
8*time.Second,
|
8*time.Second,
|
||||||
)
|
)
|
||||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||||
if probeErr == nil && result.UDPAssociateOK {
|
if probeErr == nil && result.UDPExchangeOK {
|
||||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -479,8 +479,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques
|
|||||||
8*time.Second,
|
8*time.Second,
|
||||||
)
|
)
|
||||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||||
if probeErr == nil && result.UDPAssociateOK {
|
if probeErr == nil && result.UDPExchangeOK {
|
||||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -144,6 +146,9 @@ func New(options Options) (*Server, error) {
|
|||||||
server.loadUILanguage(context.Background())
|
server.loadUILanguage(context.Background())
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", server.handleLiveness)
|
||||||
|
mux.HandleFunc("/readyz", server.handleReadiness)
|
||||||
|
mux.HandleFunc("/metrics", server.handleMetrics)
|
||||||
mux.HandleFunc("/api/health", server.handleHealth)
|
mux.HandleFunc("/api/health", server.handleHealth)
|
||||||
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
||||||
mux.HandleFunc("/api/auth/session", server.handleSession)
|
mux.HandleFunc("/api/auth/session", server.handleSession)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ var notificationChannels = []string{
|
|||||||
"bark",
|
"bark",
|
||||||
"pushplus",
|
"pushplus",
|
||||||
"wecom",
|
"wecom",
|
||||||
|
"lark",
|
||||||
}
|
}
|
||||||
|
|
||||||
var notificationFields = map[string]map[string]string{
|
var notificationFields = map[string]map[string]string{
|
||||||
@@ -65,6 +66,9 @@ var notificationFields = map[string]map[string]string{
|
|||||||
"wecom": {
|
"wecom": {
|
||||||
"urls": "strings", "payload_template": "string",
|
"urls": "strings", "payload_template": "string",
|
||||||
},
|
},
|
||||||
|
"lark": {
|
||||||
|
"url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||||
@@ -240,6 +244,20 @@ func decodeNotificationConfig(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
||||||
}
|
}
|
||||||
|
if enabled && channel == "lark" {
|
||||||
|
var resolved map[string]any
|
||||||
|
if err := json.Unmarshal(config, &resolved); err != nil {
|
||||||
|
return false, nil, fmt.Errorf("decode lark notification config: %w", err)
|
||||||
|
}
|
||||||
|
signingEnabled, _ := resolved["signing_enabled"].(bool)
|
||||||
|
if signingEnabled && configString(resolved, "url") != store.SecretMask &&
|
||||||
|
configString(resolved, "secret") == store.SecretMask {
|
||||||
|
return false, nil, errors.New("lark.secret must be re-entered when lark.url changes")
|
||||||
|
}
|
||||||
|
if err := validateLarkNotificationConfig(resolved); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return enabled, config, nil
|
return enabled, config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +283,9 @@ func validateNotificationField(
|
|||||||
if name == "text_template" {
|
if name == "text_template" {
|
||||||
limit = 32768
|
limit = 32768
|
||||||
}
|
}
|
||||||
|
if channel == "lark" && name == "payload_template" {
|
||||||
|
limit = maxLarkPayloadBytes
|
||||||
|
}
|
||||||
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
||||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
||||||
}
|
}
|
||||||
@@ -274,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,6 +321,16 @@ func validateNotificationField(
|
|||||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if channel == "lark" && name == "payload_template" && value != "" {
|
||||||
|
if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil {
|
||||||
|
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel == "lark" && name == "url" && value != "" && value != store.SecretMask {
|
||||||
|
if _, err := parseLarkWebhookURL(value); err != nil {
|
||||||
|
return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
case "integer":
|
case "integer":
|
||||||
var value int
|
var value int
|
||||||
if err := json.Unmarshal(raw, &value); err != nil {
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
@@ -387,7 +418,7 @@ func (s *Server) handleNotificationTest(
|
|||||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" {
|
||||||
writeError(
|
writeError(
|
||||||
w,
|
w,
|
||||||
http.StatusNotImplemented,
|
http.StatusNotImplemented,
|
||||||
@@ -441,6 +472,8 @@ func (s *Server) handleNotificationTest(
|
|||||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||||
|
case "lark":
|
||||||
|
err = sendLarkNotificationTest(notificationContext, resolved)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redacted := store.RedactText(err.Error(), provider)
|
redacted := store.RedactText(err.Error(), provider)
|
||||||
@@ -546,8 +579,8 @@ func (s *Server) resolveNotificationTestConfig(
|
|||||||
|
|
||||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||||
// settings form while allowing newly entered sensitive values in the same
|
// settings form while allowing newly entered sensitive values in the same
|
||||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
// request. Provider webhook URLs can be sensitive lists, unlike the
|
||||||
// used by the other notification channels.
|
// string-based secrets used by the other notification channels.
|
||||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
return existing
|
return existing
|
||||||
@@ -595,6 +628,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
|||||||
}
|
}
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return validateWecomNotificationConfig(config)
|
return validateWecomNotificationConfig(config)
|
||||||
|
case "lark":
|
||||||
|
return validateLarkNotificationConfig(config)
|
||||||
case "telegram":
|
case "telegram":
|
||||||
token := configString(config, "bot_token")
|
token := configString(config, "bot_token")
|
||||||
if token == "" || token == store.SecretMask {
|
if token == "" || token == store.SecretMask {
|
||||||
@@ -954,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
|
||||||
}
|
}
|
||||||
@@ -964,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 {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
|
func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
@@ -179,6 +179,73 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||||
|
secret := "lark-signing-secret"
|
||||||
|
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||||
|
first, err := json.Marshal(map[string]any{
|
||||||
|
"lark": map[string]any{
|
||||||
|
"enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "payload_template": template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
if bytes.Contains(recorder.Body.Bytes(), []byte("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) {
|
||||||
|
t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body)
|
||||||
|
}
|
||||||
|
response := decodeSettingsResponse(t, recorder)
|
||||||
|
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||||
|
if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask {
|
||||||
|
t.Fatalf("redacted Lark config = %#v", lark)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := json.Marshal(map[string]any{
|
||||||
|
"lark": map[string]any{
|
||||||
|
"enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": store.SecretMask, "payload_template": template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
stored, err := test.database.NotificationSetting(context.Background(), "lark")
|
||||||
|
if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) {
|
||||||
|
t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"lark": map[string]any{
|
||||||
|
"enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||||
|
"signing_enabled": false, "payload_template": template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
response := decodeSettingsResponse(t, recorder)
|
||||||
|
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||||
|
if _, exists := lark["secret"]; exists {
|
||||||
|
t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
raw, err := json.Marshal(map[string]any{
|
raw, err := json.Marshal(map[string]any{
|
||||||
@@ -232,6 +299,45 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored"
|
||||||
|
storedConfig, err := json.Marshal(map[string]any{
|
||||||
|
"url": storedURL,
|
||||||
|
"signing_enabled": true,
|
||||||
|
"secret": "stored-signing-secret",
|
||||||
|
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
||||||
|
Channel: "lark",
|
||||||
|
Config: storedConfig,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"url": store.SecretMask,
|
||||||
|
"signing_enabled": true,
|
||||||
|
"secret": store.SecretMask,
|
||||||
|
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved["url"] != storedURL {
|
||||||
|
t.Fatalf("resolved URL = %#v", resolved["url"])
|
||||||
|
}
|
||||||
|
if resolved["secret"] != "stored-signing-secret" {
|
||||||
|
t.Fatalf("resolved secret = %#v", resolved["secret"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -284,6 +390,41 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
|||||||
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
||||||
code: "invalid_notification_config",
|
code: "invalid_notification_config",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "invalid Lark payload template",
|
||||||
|
body: `{"lark":{"enabled":true,"payload_template":"[]"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled Lark config without webhook URL",
|
||||||
|
body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled Lark signing without secret",
|
||||||
|
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "changed Lark URL with masked signing secret",
|
||||||
|
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "insecure Lark group bot URL",
|
||||||
|
body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-Lark group bot URL",
|
||||||
|
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webhook URL with embedded credentials",
|
||||||
|
body: `{"webhook":{"enabled":true,"urls":["http://user:[email protected]"]}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "null body",
|
name: "null body",
|
||||||
body: `null`,
|
body: `null`,
|
||||||
@@ -858,6 +999,41 @@ func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotificationProxyAcceptsAuthenticatedURL(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"http://user:[email protected]:8080"}}`
|
||||||
|
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
response := decodeSettingsResponse(t, recorder)
|
||||||
|
data, ok := response["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("data missing: %#v", response)
|
||||||
|
}
|
||||||
|
telegram, ok := data["telegram"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("telegram response missing: %#v", data)
|
||||||
|
}
|
||||||
|
if telegram["proxy"] != "http://user:[email protected]:8080" {
|
||||||
|
t.Fatalf("proxy not preserved: %#v", telegram["proxy"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationProxyRejectsMalformedURL(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"not-a-url"}}`
|
||||||
|
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
response := decodeSettingsResponse(t, recorder)
|
||||||
|
detail, ok := response["error"].(map[string]any)
|
||||||
|
if !ok || detail["code"] != "invalid_notification_config" {
|
||||||
|
t.Fatalf("error = %#v", detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
|
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)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
|
|
||||||
const smsNotificationPollInterval = 2 * time.Second
|
const smsNotificationPollInterval = 2 * time.Second
|
||||||
|
|
||||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"}
|
||||||
|
|
||||||
type smsNotification struct {
|
type smsNotification struct {
|
||||||
DeviceID string
|
DeviceID string
|
||||||
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
|||||||
|
|
||||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||||
switch channel {
|
switch channel {
|
||||||
case "bark", "email", "webhook", "wecom":
|
case "bark", "email", "webhook", "wecom", "lark":
|
||||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -204,6 +204,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
|||||||
return sendWebhookSMSNotification(ctx, config, message)
|
return sendWebhookSMSNotification(ctx, config, message)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||||
|
case "lark":
|
||||||
|
return sendLarkNotification(ctx, config, larkSMSValues(message))
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) {
|
||||||
|
message := smsNotification{
|
||||||
|
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||||
|
Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello",
|
||||||
|
}
|
||||||
|
smsValues := larkSMSValues(message)
|
||||||
|
if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" ||
|
||||||
|
smsValues["message"] != message.Text() || smsValues["content"] != "hello" ||
|
||||||
|
smsValues["device_label"] != "EC20" {
|
||||||
|
t.Fatalf("Lark SMS values = %#v", smsValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskValues := larkAutomaticTaskValues(automaticTaskNotification{
|
||||||
|
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||||
|
})
|
||||||
|
if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" {
|
||||||
|
t.Fatalf("Lark automatic task values = %#v", taskValues)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||||
|
if taskValues[name] != "" {
|
||||||
|
t.Fatalf("%s = %q, want empty", name, taskValues[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||||
valid := map[string]map[string]any{
|
valid := map[string]map[string]any{
|
||||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||||
@@ -76,6 +101,12 @@ func TestValidateSMSNotificationConfig(t *testing.T) {
|
|||||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||||
},
|
},
|
||||||
|
"lark": {
|
||||||
|
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret",
|
||||||
|
"signing_enabled": true,
|
||||||
|
"secret": "signing-secret",
|
||||||
|
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for channel, config := range valid {
|
for channel, config := range valid {
|
||||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||||
|
|||||||
@@ -1981,7 +1981,7 @@ func (bot *telegramBot) handleATCommand(ctx context.Context, config telegramRunt
|
|||||||
|
|
||||||
func (bot *telegramBot) executeATCommand(ctx context.Context, deviceID, command string) (string, error) {
|
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()
|
||||||
|
}
|
||||||
@@ -917,6 +917,56 @@ func TestNotificationArraySecretPreservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
database := openTestStore(t, ":memory:")
|
||||||
|
originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||||
|
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||||
|
Channel: "lark", Enabled: true,
|
||||||
|
Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setting, err := database.NotificationSetting(ctx, "lark")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var redacted map[string]any
|
||||||
|
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if redacted["url"] != SecretMask || redacted["secret"] != SecretMask {
|
||||||
|
t.Fatalf("redacted Lark config = %#v", redacted)
|
||||||
|
}
|
||||||
|
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||||
|
Channel: "lark", Enabled: true,
|
||||||
|
Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setting, err = database.NotificationSetting(ctx, "lark")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) {
|
||||||
|
t.Fatalf("stored Lark config = %s", setting.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) {
|
||||||
|
setting := NotificationSetting{
|
||||||
|
Config: json.RawMessage(`{"url":"","secret":""}`),
|
||||||
|
SensitiveFields: []string{"url", "secret"},
|
||||||
|
}
|
||||||
|
var redacted map[string]any
|
||||||
|
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if redacted["url"] != "" || redacted["secret"] != "" {
|
||||||
|
t.Fatalf("empty sensitive values were masked: %#v", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database := openTestStore(t, ":memory:")
|
database := openTestStore(t, ":memory:")
|
||||||
|
|||||||
@@ -610,6 +610,9 @@ func mergeJSONSecrets(
|
|||||||
func redactJSONValue(value any, replacement string) any {
|
func redactJSONValue(value any, replacement string) any {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
if typed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return replacement
|
return replacement
|
||||||
case []any:
|
case []any:
|
||||||
result := make([]any, len(typed))
|
result := make([]any, len(typed))
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
|||||||
return []string{"token"}
|
return []string{"token"}
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return []string{"urls"}
|
return []string{"urls"}
|
||||||
|
case "lark":
|
||||||
|
return []string{"url", "secret"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLongSMSReassemblySurvivesServiceRestart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||||
|
const (
|
||||||
|
deviceID = "dajiang"
|
||||||
|
imei = "867394042309830"
|
||||||
|
peer = "+447700900123"
|
||||||
|
)
|
||||||
|
messageID := StableConcatMessageID("ims", imei, deviceID, peer, 27, 2)
|
||||||
|
|
||||||
|
database, err := Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSaveDevice(t, database, deviceID, "大疆")
|
||||||
|
first, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第一段:安全提醒,",
|
||||||
|
Timestamp: time.Unix(1_700_000_000, 0).UTC(), Status: "received", Source: "ims",
|
||||||
|
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
|
||||||
|
t.Fatal("partial message must not be ready before restart")
|
||||||
|
}
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err = Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
second, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||||
|
Timestamp: time.Unix(1_700_000_030, 0).UTC(), Status: "received", Source: "ims",
|
||||||
|
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.Body != "第一段:安全提醒,第二段:请通过官方渠道核实。" ||
|
||||||
|
!ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
|
||||||
|
t.Fatalf("reassembled message after restart = %#v", second)
|
||||||
|
}
|
||||||
|
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: deviceID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 || messages[0].ID != second.ID {
|
||||||
|
t.Fatalf("stored messages after restart = %#v, want one merged row", messages)
|
||||||
|
}
|
||||||
|
redelivered, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||||
|
Status: "received", Source: "ims", PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if redelivered.ID != second.ID || redelivered.Body != second.Body {
|
||||||
|
t.Fatalf("redelivery duplicated or changed message: %#v", redelivered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartDeliveryReportsSurviveServiceRestart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||||
|
database, err := Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSaveDevice(t, database, "dajiang", "大疆")
|
||||||
|
extra := json.RawMessage(`{"transport":"ims","part_results":[{"reference":51},{"reference":52}]}`)
|
||||||
|
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: "ims-submit-restart", DeviceID: "dajiang", IMSI: "23433",
|
||||||
|
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
|
||||||
|
Status: "accepted_by_ims", Source: "ims", PartsTotal: 2,
|
||||||
|
DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err = Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||||
|
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||||
|
MessageReference: 51, StatusCode: 0, DeliveryState: "delivered",
|
||||||
|
})
|
||||||
|
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
|
||||||
|
t.Fatalf("first report after restart = (%#v, %v)", first, err)
|
||||||
|
}
|
||||||
|
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||||
|
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||||
|
MessageReference: 52, StatusCode: 0, DeliveryState: "delivered",
|
||||||
|
})
|
||||||
|
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
|
||||||
|
t.Fatalf("second report after restart = (%#v, %v)", second, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectSystemdUnitUsesExplicitOverride(t *testing.T) {
|
||||||
|
t.Setenv("VOCAT_SYSTEMD_UNIT", "vocat-test.service")
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if got := detectSystemdUnit(logger); got != "vocat-test.service" {
|
||||||
|
t.Fatalf("detectSystemdUnit() = %q, want vocat-test.service", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdUnitFromCgroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "cgroup v2", data: "0::/system.slice/vocat-test.service\n", want: "vocat-test.service"},
|
||||||
|
{name: "legacy", data: "1:name=systemd:/system.slice/[email protected]\n", want: "[email protected]"},
|
||||||
|
{name: "no service", data: "0::/user.slice/user-1000.slice/session-1.scope\n", want: ""},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := systemdUnitFromCgroup(test.data); got != test.want {
|
||||||
|
t.Fatalf("systemdUnitFromCgroup() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSystemdUnitRejectsArgumentsAndPaths(t *testing.T) {
|
||||||
|
for _, value := range []string{"vocat", "../vocat.service", "vocat.service --now", "vocat.service/other"} {
|
||||||
|
if validSystemdUnit.MatchString(value) {
|
||||||
|
t.Fatalf("validSystemdUnit unexpectedly accepted %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -320,17 +322,63 @@ func RestartService(logger *slog.Logger) error {
|
|||||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||||
}
|
}
|
||||||
|
unit := detectSystemdUnit(logger)
|
||||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||||
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||||
return fmt.Errorf("systemctl restart vocat: %w", err)
|
return fmt.Errorf("systemctl restart %s: %w", unit, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var validSystemdUnit = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`)
|
||||||
|
|
||||||
|
func detectSystemdUnit(logger *slog.Logger) string {
|
||||||
|
if configured := strings.TrimSpace(os.Getenv("VOCAT_SYSTEMD_UNIT")); validSystemdUnit.MatchString(configured) {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||||
|
if unit := systemdUnitFromCgroup(string(data)); unit != "" {
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Some cgroup namespaces hide the unit name. Query loaded services and
|
||||||
|
// identify the unit whose MainPID is this process before falling back.
|
||||||
|
list := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain")
|
||||||
|
if output, err := list.Output(); err == nil {
|
||||||
|
pid := strconv.Itoa(os.Getpid())
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || !validSystemdUnit.MatchString(fields[0]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
show := exec.Command("systemctl", "show", fields[0], "--property=MainPID", "--value")
|
||||||
|
if value, showErr := show.Output(); showErr == nil && strings.TrimSpace(string(value)) == pid {
|
||||||
|
return fields[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("could not identify the current systemd unit; using vocat.service", "hint", "set VOCAT_SYSTEMD_UNIT for a custom unit")
|
||||||
|
}
|
||||||
|
return "vocat.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemdUnitFromCgroup(data string) string {
|
||||||
|
for _, line := range strings.Split(data, "\n") {
|
||||||
|
for _, part := range strings.Split(line, "/") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if validSystemdUnit.MatchString(part) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// resolveDefaultTarget returns the conventional install path when present,
|
// resolveDefaultTarget returns the conventional install path when present,
|
||||||
// falling back to the running executable. This lets `vocat update` "just work"
|
// falling back to the running executable. This lets `vocat update` "just work"
|
||||||
// on the standard systemd host without flags.
|
// on the standard systemd host without flags.
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ package vowifi
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
CarrierProfileSchemaVersion = 1
|
||||||
CarrierProfileStandard = "standard-3gpp"
|
CarrierProfileStandard = "standard-3gpp"
|
||||||
IKEProposalModern = "modern"
|
IKEProposalModern = "modern"
|
||||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||||
@@ -28,13 +35,41 @@ type CarrierProfile struct {
|
|||||||
EPDG string
|
EPDG string
|
||||||
IKEProposal string
|
IKEProposal string
|
||||||
AdvertiseEAPOnly bool
|
AdvertiseEAPOnly bool
|
||||||
|
AllowSMSWithoutContactConfirmation bool
|
||||||
|
IMSRegisterOptions IMSRegisterOptions
|
||||||
IMSTransport string
|
IMSTransport string
|
||||||
IMSIdentityProfile string
|
IMSIdentityProfile string
|
||||||
IMSRegisterProfile string
|
IMSRegisterProfile string
|
||||||
IMSIPSecEncryption string
|
IMSIPSecEncryption string
|
||||||
SMSCenter string
|
SMSCenter string
|
||||||
|
PANICountry string
|
||||||
|
PANINode string
|
||||||
|
IMSDialURIScheme string
|
||||||
|
IMSUserEqPhone bool
|
||||||
|
IMSVoiceCodecs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMSRegisterOptions carries carrier-specific SIP REGISTER header values.
|
||||||
|
// Pointer fields distinguish "use default" (nil) from "explicitly omit" ("").
|
||||||
|
type IMSRegisterOptions struct {
|
||||||
|
ContactFormat string
|
||||||
|
ExpirySeconds int
|
||||||
|
ContactExtraTags []string
|
||||||
|
SupportedHeader *string
|
||||||
|
AllowHeader *string
|
||||||
|
UserAgent string
|
||||||
|
PPreferredIdentity bool
|
||||||
|
PVisitedNetworkID string
|
||||||
|
PAccessNetworkInfo *string
|
||||||
|
CellularNetworkInfo string
|
||||||
|
AcceptContactTags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
IMSContactFormatStandard = "standard"
|
||||||
|
IMSContactFormatATT = "att"
|
||||||
|
)
|
||||||
|
|
||||||
type carrierProfileDocument struct {
|
type carrierProfileDocument struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Profiles []carrierProfileRule `json:"profiles"`
|
Profiles []carrierProfileRule `json:"profiles"`
|
||||||
@@ -42,44 +77,66 @@ type carrierProfileDocument struct {
|
|||||||
|
|
||||||
type carrierProfileRule struct {
|
type carrierProfileRule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Match carrierProfileMatch `json:"match"`
|
Match carrierProfileMatch `json:"match,omitzero"`
|
||||||
Route carrierProfileRoute `json:"route"`
|
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
||||||
EPDG carrierProfileEPDG `json:"epdg"`
|
Route carrierProfileRoute `json:"route,omitzero"`
|
||||||
IKE carrierProfileIKE `json:"ike"`
|
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
||||||
IMS carrierProfileIMS `json:"ims"`
|
IKE carrierProfileIKE `json:"ike,omitzero"`
|
||||||
|
IMS carrierProfileIMS `json:"ims,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileMatch struct {
|
type carrierProfileMatch struct {
|
||||||
HomePLMNs []string `json:"home_plmns"`
|
HomePLMNs []string `json:"home_plmns,omitempty"`
|
||||||
IMSIPrefixes []string `json:"imsi_prefixes"`
|
IMSIPrefixes []string `json:"imsi_prefixes,omitempty"`
|
||||||
ICCIDPrefixes []string `json:"iccid_prefixes"`
|
ICCIDPrefixes []string `json:"iccid_prefixes,omitempty"`
|
||||||
SPNs []string `json:"spns"`
|
SPNs []string `json:"spns,omitempty"`
|
||||||
GID1Prefixes []string `json:"gid1_prefixes"`
|
GID1Prefixes []string `json:"gid1_prefixes,omitempty"`
|
||||||
GID2Prefixes []string `json:"gid2_prefixes"`
|
GID2Prefixes []string `json:"gid2_prefixes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileRoute struct {
|
type carrierProfileRoute struct {
|
||||||
MCC string `json:"mcc"`
|
MCC string `json:"mcc,omitempty"`
|
||||||
MNC string `json:"mnc"`
|
MNC string `json:"mnc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileEPDG struct {
|
type carrierProfileEPDG struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname,omitempty"`
|
||||||
DNSHosts []string `json:"dns_hosts"`
|
DNSHosts []string `json:"dns_hosts,omitempty"`
|
||||||
DNSClientSubnet string `json:"dns_client_subnet"`
|
DNSClientSubnet string `json:"dns_client_subnet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileIKE struct {
|
type carrierProfileIKE struct {
|
||||||
Proposal string `json:"proposal"`
|
Proposal string `json:"proposal,omitempty"`
|
||||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only"`
|
AdvertiseEAPOnly *bool `json:"advertise_eap_only,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type carrierProfileIMS struct {
|
type carrierProfileIMS struct {
|
||||||
Transport string `json:"transport"`
|
Transport string `json:"transport,omitempty"`
|
||||||
IdentityProfile string `json:"identity_profile"`
|
IdentityProfile string `json:"identity_profile,omitempty"`
|
||||||
RegisterProfile string `json:"register_profile"`
|
RegisterProfile string `json:"register_profile,omitempty"`
|
||||||
IPSecEncryption string `json:"ipsec_encryption"`
|
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
||||||
SMSCenter string `json:"sms_center"`
|
SMSCenter string `json:"sms_center,omitempty"`
|
||||||
|
PANICountry string `json:"pani_country,omitempty"`
|
||||||
|
PANINode string `json:"pani_node,omitempty"`
|
||||||
|
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
||||||
|
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
||||||
|
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
||||||
|
RegisterOptions carrierProfileRegisterOptions `json:"register_options,omitzero"`
|
||||||
|
AllowSMSWithoutContactConfirmation *bool `json:"allow_sms_without_contact_confirmation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileRegisterOptions struct {
|
||||||
|
ContactFormat string `json:"contact_format,omitempty"`
|
||||||
|
ExpirySeconds int `json:"expiry_seconds,omitempty"`
|
||||||
|
ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
|
||||||
|
SupportedHeader *string `json:"supported_header,omitempty"`
|
||||||
|
AllowHeader *string `json:"allow_header,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
|
||||||
|
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
|
||||||
|
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
|
||||||
|
CellularNetworkInfo string `json:"cellular_network_info,omitempty"`
|
||||||
|
AcceptContactTags []string `json:"accept_contact_tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed carrier_profiles.json
|
//go:embed carrier_profiles.json
|
||||||
@@ -87,36 +144,135 @@ var carrierProfilesJSON []byte
|
|||||||
|
|
||||||
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||||
|
|
||||||
|
var externalCarrierProfiles = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
rules []carrierProfileRule
|
||||||
|
}{}
|
||||||
|
|
||||||
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||||
var document carrierProfileDocument
|
rules, err := loadCarrierProfiles(encoded)
|
||||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
if err != nil {
|
||||||
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||||
}
|
}
|
||||||
if document.Version != 1 {
|
return rules
|
||||||
panic(fmt.Sprintf("vowifi: unsupported carrier profile version %d", document.Version))
|
}
|
||||||
|
|
||||||
|
func loadCarrierProfiles(encoded []byte) ([]carrierProfileRule, error) {
|
||||||
|
var document carrierProfileDocument
|
||||||
|
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if document.Version != CarrierProfileSchemaVersion {
|
||||||
|
return nil, fmt.Errorf("unsupported carrier profile version %d", document.Version)
|
||||||
}
|
}
|
||||||
seen := make(map[string]struct{}, len(document.Profiles))
|
seen := make(map[string]struct{}, len(document.Profiles))
|
||||||
for index := range document.Profiles {
|
for index := range document.Profiles {
|
||||||
rule := &document.Profiles[index]
|
rule := &document.Profiles[index]
|
||||||
rule.ID = strings.TrimSpace(rule.ID)
|
rule.ID = strings.TrimSpace(rule.ID)
|
||||||
if rule.ID == "" {
|
if rule.ID == "" {
|
||||||
panic("vowifi: carrier profile ID is empty")
|
return nil, fmt.Errorf("carrier profile %d ID is empty", index)
|
||||||
}
|
}
|
||||||
if _, duplicate := seen[rule.ID]; duplicate {
|
if _, duplicate := seen[rule.ID]; duplicate {
|
||||||
panic("vowifi: duplicate carrier profile " + rule.ID)
|
return nil, errors.New("duplicate carrier profile " + rule.ID)
|
||||||
}
|
}
|
||||||
seen[rule.ID] = struct{}{}
|
seen[rule.ID] = struct{}{}
|
||||||
if !validCarrierProfileRule(*rule) {
|
if !validCarrierProfileRule(*rule) {
|
||||||
panic("vowifi: invalid carrier profile " + rule.ID)
|
return nil, errors.New("invalid carrier profile " + rule.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return document.Profiles
|
return document.Profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCarrierProfileDirectory replaces the installed profile set with all
|
||||||
|
// valid JSON documents in dir. A missing directory is an empty set. Profiles
|
||||||
|
// are sorted by filename; later profiles win only when selector specificity is
|
||||||
|
// equal, so a broad installed PLMN rule cannot hide a constrained MVNO rule.
|
||||||
|
func LoadCarrierProfileDirectory(dir string) error {
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
return errors.New("carrier profile directory is empty")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
externalCarrierProfiles.Lock()
|
||||||
|
externalCarrierProfiles.rules = nil
|
||||||
|
externalCarrierProfiles.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read carrier profile directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
if len(entries) > 256 {
|
||||||
|
return fmt.Errorf("carrier profile directory %q contains %d entries; maximum is 256", dir, len(entries))
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||||
|
loaded := make([]carrierProfileRule, 0, len(entries))
|
||||||
|
seen := make(map[string]string)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat carrier profile %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if info.Size() > 1<<20 {
|
||||||
|
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open carrier profile %q: %w", path, err)
|
||||||
|
}
|
||||||
|
encoded, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||||
|
closeErr := file.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("read carrier profile %q: %w", path, readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("close carrier profile %q: %w", path, closeErr)
|
||||||
|
}
|
||||||
|
if len(encoded) > 1<<20 {
|
||||||
|
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||||
|
}
|
||||||
|
rules, err := loadCarrierProfiles(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load carrier profile %q: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if previous := seen[rule.ID]; previous != "" {
|
||||||
|
return fmt.Errorf("carrier profile %q is duplicated in %q and %q", rule.ID, previous, path)
|
||||||
|
}
|
||||||
|
seen[rule.ID] = path
|
||||||
|
loaded = append(loaded, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
externalCarrierProfiles.Lock()
|
||||||
|
externalCarrierProfiles.rules = loaded
|
||||||
|
externalCarrierProfiles.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func carrierProfilesSnapshot() []carrierProfileRule {
|
||||||
|
externalCarrierProfiles.RLock()
|
||||||
|
defer externalCarrierProfiles.RUnlock()
|
||||||
|
result := make([]carrierProfileRule, 0, len(builtinCarrierProfiles)+len(externalCarrierProfiles.rules))
|
||||||
|
result = append(result, builtinCarrierProfiles...)
|
||||||
|
result = append(result, externalCarrierProfiles.rules...)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||||
match := rule.Match
|
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||||
if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
if !emptyCarrierProfileMatch(rule.Match) {
|
||||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 {
|
matches = append(matches, rule.Match)
|
||||||
|
}
|
||||||
|
matches = append(matches, rule.MatchAny...)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if emptyCarrierProfileMatch(match) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, plmn := range match.HomePLMNs {
|
for _, plmn := range match.HomePLMNs {
|
||||||
@@ -124,6 +280,27 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, prefix := range match.IMSIPrefixes {
|
||||||
|
if len(prefix) < 5 || len(prefix) > 18 || !decimalString(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range match.ICCIDPrefixes {
|
||||||
|
if len(prefix) < 5 || len(prefix) > 22 || !decimalString(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range append(append([]string(nil), match.GID1Prefixes...), match.GID2Prefixes...) {
|
||||||
|
if len(prefix) < 1 || len(prefix) > 64 || !hexString(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, spn := range match.SPNs {
|
||||||
|
if strings.TrimSpace(spn) == "" || len(spn) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
||||||
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
||||||
return false
|
return false
|
||||||
@@ -140,6 +317,75 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
|
|||||||
encryption != "aes-cbc" && encryption != "null" {
|
encryption != "aes-cbc" && encryption != "null" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
||||||
|
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, codec := range rule.IMS.VoiceCodecs {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(codec)) {
|
||||||
|
case "PCMA", "PCMU", "AMR", "AMR-WB":
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.IMS.RegisterOptions.ExpirySeconds != 0 &&
|
||||||
|
(rule.IMS.RegisterOptions.ExpirySeconds < 60 || rule.IMS.RegisterOptions.ExpirySeconds > 86400) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
|
||||||
|
format != IMSContactFormatStandard && format != IMSContactFormatATT {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
|
||||||
|
if strings.ContainsAny(value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []*string{rule.IMS.RegisterOptions.SupportedHeader, rule.IMS.RegisterOptions.AllowHeader, rule.IMS.RegisterOptions.PAccessNetworkInfo} {
|
||||||
|
if value != nil && strings.ContainsAny(*value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
|
||||||
|
if strings.ContainsAny(value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range rule.IMS.RegisterOptions.AcceptContactTags {
|
||||||
|
if strings.ContainsAny(value, "\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyCarrierProfileMatch(match carrierProfileMatch) bool {
|
||||||
|
return len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||||
|
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexString(value string) bool {
|
||||||
|
for _, item := range value {
|
||||||
|
if item >= '0' && item <= '9' || item >= 'a' && item <= 'f' || item >= 'A' && item <= 'F' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return value != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalString(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range value {
|
||||||
|
if item < '0' || item > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,11 +401,13 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
IMSIdentityProfile: IMSProfileStandard,
|
IMSIdentityProfile: IMSProfileStandard,
|
||||||
IMSRegisterProfile: IMSProfileStandard,
|
IMSRegisterProfile: IMSProfileStandard,
|
||||||
IMSIPSecEncryption: "aes-cbc",
|
IMSIPSecEncryption: "aes-cbc",
|
||||||
|
IMSDialURIScheme: "tel",
|
||||||
|
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||||
}
|
}
|
||||||
bestScore := -1
|
bestScore := -1
|
||||||
for _, rule := range builtinCarrierProfiles {
|
for _, rule := range carrierProfilesSnapshot() {
|
||||||
score, source, matched := matchCarrierProfile(rule.Match, identity)
|
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||||
if !matched || score <= bestScore {
|
if !matched || score < bestScore {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bestScore = score
|
bestScore = score
|
||||||
@@ -168,6 +416,28 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchCarrierProfileRule evaluates each selector set as an alternative. This
|
||||||
|
// mirrors carrier-bundle and Android carrier-ID semantics: fields inside one
|
||||||
|
// selector are ANDed, while separate selector records for the same brand are
|
||||||
|
// ORed (for example, giffgaff can be identified by either GID1 or SPN).
|
||||||
|
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
||||||
|
bestScore := -1
|
||||||
|
bestSource := ""
|
||||||
|
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||||
|
if !emptyCarrierProfileMatch(rule.Match) {
|
||||||
|
matches = append(matches, rule.Match)
|
||||||
|
}
|
||||||
|
matches = append(matches, rule.MatchAny...)
|
||||||
|
for _, match := range matches {
|
||||||
|
score, source, matched := matchCarrierProfile(match, identity)
|
||||||
|
if matched && score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestScore, bestSource, bestScore >= 0
|
||||||
|
}
|
||||||
|
|
||||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
||||||
score := 0
|
score := 0
|
||||||
sources := make([]string, 0, 6)
|
sources := make([]string, 0, 6)
|
||||||
@@ -256,9 +526,81 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
|
|||||||
base.IMSIPSecEncryption = value
|
base.IMSIPSecEncryption = value
|
||||||
}
|
}
|
||||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||||
|
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
||||||
|
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
||||||
|
base.IMSDialURIScheme = value
|
||||||
|
}
|
||||||
|
if rule.IMS.UserEqPhone != nil {
|
||||||
|
base.IMSUserEqPhone = *rule.IMS.UserEqPhone
|
||||||
|
}
|
||||||
|
if len(rule.IMS.VoiceCodecs) > 0 {
|
||||||
|
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
||||||
|
}
|
||||||
|
if rule.IMS.AllowSMSWithoutContactConfirmation != nil {
|
||||||
|
base.AllowSMSWithoutContactConfirmation = *rule.IMS.AllowSMSWithoutContactConfirmation
|
||||||
|
}
|
||||||
|
base.IMSRegisterOptions = applyRegisterOptions(base.IMSRegisterOptions, rule.IMS.RegisterOptions)
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOptions) IMSRegisterOptions {
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(rule.ContactFormat)); value != "" {
|
||||||
|
base.ContactFormat = value
|
||||||
|
}
|
||||||
|
if rule.ExpirySeconds != 0 {
|
||||||
|
base.ExpirySeconds = rule.ExpirySeconds
|
||||||
|
}
|
||||||
|
if len(rule.ContactExtraTags) > 0 {
|
||||||
|
base.ContactExtraTags = append([]string(nil), rule.ContactExtraTags...)
|
||||||
|
}
|
||||||
|
if rule.SupportedHeader != nil {
|
||||||
|
value := strings.TrimSpace(*rule.SupportedHeader)
|
||||||
|
base.SupportedHeader = &value
|
||||||
|
}
|
||||||
|
if rule.AllowHeader != nil {
|
||||||
|
value := strings.TrimSpace(*rule.AllowHeader)
|
||||||
|
base.AllowHeader = &value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.UserAgent); value != "" {
|
||||||
|
base.UserAgent = value
|
||||||
|
}
|
||||||
|
if rule.PPreferredIdentity {
|
||||||
|
base.PPreferredIdentity = true
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.PVisitedNetworkID); value != "" {
|
||||||
|
base.PVisitedNetworkID = value
|
||||||
|
}
|
||||||
|
if rule.PAccessNetworkInfo != nil {
|
||||||
|
value := strings.TrimSpace(*rule.PAccessNetworkInfo)
|
||||||
|
base.PAccessNetworkInfo = &value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.CellularNetworkInfo); value != "" {
|
||||||
|
base.CellularNetworkInfo = value
|
||||||
|
}
|
||||||
|
if len(rule.AcceptContactTags) > 0 {
|
||||||
|
base.AcceptContactTags = append([]string(nil), rule.AcceptContactTags...)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeVoiceCodecs(values []string) []string {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[value]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func canonicalPLMN(mcc, mnc string) string {
|
func canonicalPLMN(mcc, mnc string) string {
|
||||||
mcc = strings.TrimSpace(mcc)
|
mcc = strings.TrimSpace(mcc)
|
||||||
mnc = strings.TrimSpace(mnc)
|
mnc = strings.TrimSpace(mnc)
|
||||||
@@ -323,7 +665,7 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
|||||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||||
func EPDGDNSClientSubnet(host string) string {
|
func EPDGDNSClientSubnet(host string) string {
|
||||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
for _, rule := range builtinCarrierProfiles {
|
for _, rule := range carrierProfilesSnapshot() {
|
||||||
for _, candidate := range rule.EPDG.DNSHosts {
|
for _, candidate := range rule.EPDG.DNSHosts {
|
||||||
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
||||||
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
||||||
|
|||||||
@@ -81,6 +81,53 @@ func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
identity SIMIdentity
|
||||||
|
source string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Apple GID1 selector",
|
||||||
|
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
|
||||||
|
source: "hplmn+gid1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Android SPN selector",
|
||||||
|
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"},
|
||||||
|
source: "hplmn+spn",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
profile := ResolveCarrierProfile(test.identity)
|
||||||
|
if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source {
|
||||||
|
t.Fatalf("giffgaff profile = %#v", profile)
|
||||||
|
}
|
||||||
|
if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone {
|
||||||
|
t.Fatalf("giffgaff IMS settings = %#v", profile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
generic := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10",
|
||||||
|
})
|
||||||
|
if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" {
|
||||||
|
t.Fatalf("generic O2 profile = %#v", generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) {
|
||||||
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||||
|
HomeMCC: "234", HomeMNC: "33",
|
||||||
|
})
|
||||||
|
if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" {
|
||||||
|
t.Fatalf("EE-hosted profile = %#v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
||||||
for _, mnc := range []string{"03", "003"} {
|
for _, mnc := range []string{"03", "003"} {
|
||||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
||||||
@@ -98,3 +145,13 @@ func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
|||||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveCarrierProfileDITOPhilippinesUsesLegacyIKE(t *testing.T) {
|
||||||
|
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "515", HomeMNC: "66"})
|
||||||
|
if profile.ID != "dito-philippines" {
|
||||||
|
t.Fatalf("DITO profile = %#v", profile)
|
||||||
|
}
|
||||||
|
if profile.IKEProposal != IKEProposalLegacy {
|
||||||
|
t.Fatalf("DITO IKE proposal = %q, want %q", profile.IKEProposal, IKEProposalLegacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,850 @@
|
|||||||
|
package vowifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"howett.net/plist"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxIPCCBytes = 32 << 20
|
||||||
|
maxIPCCFiles = 512
|
||||||
|
maxIPCCPlistBytes = 4 << 20
|
||||||
|
maxIPCCPlistTotalBytes = 64 << 20
|
||||||
|
installedProfileFileMode = 0o600
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedSIMPLMN = regexp.MustCompile(`^[0-9]{5,6}$`)
|
||||||
|
|
||||||
|
// IPCCImportOptions controls deterministic bundle selection and profile ID
|
||||||
|
// generation. Bundle may be a full archive directory or the final .bundle
|
||||||
|
// name. ProfileID overrides the generated, filesystem-safe ID.
|
||||||
|
type IPCCImportOptions struct {
|
||||||
|
Bundle string
|
||||||
|
ProfileID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPCCImportWarning describes a value that was ambiguous, unsafe, or outside
|
||||||
|
// VoCat's portable carrier-profile schema. Such values are reported but never
|
||||||
|
// copied into the installed profile.
|
||||||
|
type IPCCImportWarning struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPCCImportResult contains a reviewable carrier-profile document. Document
|
||||||
|
// is complete JSON and can be installed without retaining the Apple archive.
|
||||||
|
type IPCCImportResult struct {
|
||||||
|
SourceFile string `json:"source_file"`
|
||||||
|
SourceSHA256 string `json:"source_sha256"`
|
||||||
|
Bundle string `json:"bundle"`
|
||||||
|
CarrierName string `json:"carrier_name"`
|
||||||
|
ProfileID string `json:"profile_id"`
|
||||||
|
Document json.RawMessage `json:"document"`
|
||||||
|
Warnings []IPCCImportWarning `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipccPlist struct {
|
||||||
|
name string
|
||||||
|
root map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipccWarningSet struct {
|
||||||
|
items []IPCCImportWarning
|
||||||
|
seen map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (set *ipccWarningSet) add(code, message, plistPath string) {
|
||||||
|
if set.seen == nil {
|
||||||
|
set.seen = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
item := IPCCImportWarning{Code: code, Message: message, Path: plistPath}
|
||||||
|
// Device-family override plists often repeat the same setting. Preserve the
|
||||||
|
// first concrete path while keeping the review output compact.
|
||||||
|
key := code + "\x00" + message
|
||||||
|
if _, duplicate := set.seen[key]; duplicate {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set.seen[key] = struct{}{}
|
||||||
|
set.items = append(set.items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportCarrierIPCC converts a local Apple .ipcc/.zip archive into one
|
||||||
|
// reviewable VoCat carrier profile. It never contacts Apple and never installs the
|
||||||
|
// result. Device-specific and security-weakening values are deliberately
|
||||||
|
// omitted with structured warnings.
|
||||||
|
func ImportCarrierIPCC(filePath string, options IPCCImportOptions) (IPCCImportResult, error) {
|
||||||
|
filePath = strings.TrimSpace(filePath)
|
||||||
|
if filePath == "" {
|
||||||
|
return IPCCImportResult{}, errors.New("IPCC path is empty")
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("stat IPCC %q: %w", filePath, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("IPCC %q is not a regular file", filePath)
|
||||||
|
}
|
||||||
|
if info.Size() <= 0 || info.Size() > maxIPCCBytes {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("IPCC %q size %d is outside 1..%d bytes", filePath, info.Size(), maxIPCCBytes)
|
||||||
|
}
|
||||||
|
encoded, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("read IPCC %q: %w", filePath, err)
|
||||||
|
}
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(encoded), int64(len(encoded)))
|
||||||
|
if err != nil {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("open IPCC %q: %w", filePath, err)
|
||||||
|
}
|
||||||
|
if len(archive.File) > maxIPCCFiles {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("IPCC contains %d files; maximum is %d", len(archive.File), maxIPCCFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleRoots := carrierBundleRoots(archive.File)
|
||||||
|
bundleRoot, err := selectCarrierBundle(bundleRoots, options.Bundle)
|
||||||
|
if err != nil {
|
||||||
|
return IPCCImportResult{}, err
|
||||||
|
}
|
||||||
|
plists, err := readCarrierBundlePlists(archive.File, bundleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return IPCCImportResult{}, err
|
||||||
|
}
|
||||||
|
primary := plists[0]
|
||||||
|
warnings := &ipccWarningSet{}
|
||||||
|
carrierName := firstNonempty(
|
||||||
|
plistString(primary.root["CarrierName"]),
|
||||||
|
statusBarCarrierName(primary.root),
|
||||||
|
strings.TrimSuffix(path.Base(bundleRoot), path.Ext(bundleRoot)),
|
||||||
|
)
|
||||||
|
|
||||||
|
matches, plmns, err := importCarrierSelectors(primary.root, plists, warnings)
|
||||||
|
if err != nil {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("import selectors from %s: %w", primary.name, err)
|
||||||
|
}
|
||||||
|
profileID := strings.TrimSpace(options.ProfileID)
|
||||||
|
if profileID == "" {
|
||||||
|
profileID = generatedIPCCProfileID(carrierName, plmns)
|
||||||
|
}
|
||||||
|
if !validInstalledProfileID(profileID) {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("profile ID %q must match [a-z0-9][a-z0-9._-]{0,63}", profileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := carrierProfileRule{ID: profileID}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
rule.Match = matches[0]
|
||||||
|
} else {
|
||||||
|
rule.MatchAny = matches
|
||||||
|
}
|
||||||
|
importCarrierEPDG(&rule, plists, warnings)
|
||||||
|
importCarrierIKE(&rule, plists, warnings)
|
||||||
|
importCarrierIMS(&rule, plists, warnings)
|
||||||
|
inspectIgnoredCarrierFields(plists, warnings)
|
||||||
|
if !validCarrierProfileRule(rule) {
|
||||||
|
return IPCCImportResult{}, errors.New("converted IPCC profile is not valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(encoded)
|
||||||
|
document := struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
Profiles []carrierProfileRule `json:"profiles"`
|
||||||
|
}{
|
||||||
|
Version: CarrierProfileSchemaVersion,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"source": "user-supplied Apple carrier bundle",
|
||||||
|
"source_sha256": hex.EncodeToString(sum[:]),
|
||||||
|
"bundle": bundleRoot,
|
||||||
|
"generated_by": "vocat carrier import-ipcc",
|
||||||
|
},
|
||||||
|
Profiles: []carrierProfileRule{rule},
|
||||||
|
}
|
||||||
|
documentJSON, err := json.MarshalIndent(document, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return IPCCImportResult{}, fmt.Errorf("encode imported carrier profile: %w", err)
|
||||||
|
}
|
||||||
|
return IPCCImportResult{
|
||||||
|
SourceFile: filepath.Base(filePath),
|
||||||
|
SourceSHA256: hex.EncodeToString(sum[:]),
|
||||||
|
Bundle: bundleRoot,
|
||||||
|
CarrierName: carrierName,
|
||||||
|
ProfileID: profileID,
|
||||||
|
Document: append(documentJSON, '\n'),
|
||||||
|
Warnings: warnings.items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallCarrierIPCCResult atomically writes an already-reviewed import result
|
||||||
|
// to dir. Existing files are never replaced; importing an update therefore
|
||||||
|
// requires an explicit operator decision outside this function.
|
||||||
|
func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, error) {
|
||||||
|
if !validInstalledProfileID(result.ProfileID) {
|
||||||
|
return "", fmt.Errorf("invalid profile ID %q", result.ProfileID)
|
||||||
|
}
|
||||||
|
if len(result.Document) == 0 {
|
||||||
|
return "", errors.New("import result has no profile document")
|
||||||
|
}
|
||||||
|
if _, err := loadCarrierProfiles(result.Document); err != nil {
|
||||||
|
return "", fmt.Errorf("validate imported profile: %w", err)
|
||||||
|
}
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
return "", errors.New("carrier profile directory is empty")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("create carrier profile directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(dir, result.ProfileID+".json")
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
return "", fmt.Errorf("carrier profile %q already exists", target)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", fmt.Errorf("stat carrier profile %q: %w", target, err)
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp(dir, "."+result.ProfileID+"-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create temporary carrier profile: %w", err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
removeTemporary := true
|
||||||
|
defer func() {
|
||||||
|
_ = temporary.Close()
|
||||||
|
if removeTemporary {
|
||||||
|
_ = os.Remove(temporaryPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := temporary.Chmod(installedProfileFileMode); err != nil {
|
||||||
|
return "", fmt.Errorf("protect temporary carrier profile: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(result.Document); err != nil {
|
||||||
|
return "", fmt.Errorf("write temporary carrier profile: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
return "", fmt.Errorf("sync temporary carrier profile: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("close temporary carrier profile: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(temporaryPath, target); err != nil {
|
||||||
|
return "", fmt.Errorf("install carrier profile %q: %w", target, err)
|
||||||
|
}
|
||||||
|
removeTemporary = false
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func carrierBundleRoots(files []*zip.File) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, file := range files {
|
||||||
|
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
||||||
|
if strings.Contains(strings.ToLower(name), "/signatures/") ||
|
||||||
|
!strings.EqualFold(path.Base(name), "carrier.plist") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root := path.Dir(name)
|
||||||
|
if root == "." || root == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[root] = struct{}{}
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(seen))
|
||||||
|
for root := range seen {
|
||||||
|
result = append(result, root)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCarrierBundle(roots []string, wanted string) (string, error) {
|
||||||
|
if len(roots) == 0 {
|
||||||
|
return "", errors.New("IPCC contains no carrier.plist bundle")
|
||||||
|
}
|
||||||
|
wanted = strings.TrimSpace(strings.ReplaceAll(wanted, "\\", "/"))
|
||||||
|
if wanted != "" {
|
||||||
|
for _, root := range roots {
|
||||||
|
base := path.Base(root)
|
||||||
|
if strings.EqualFold(root, wanted) || strings.EqualFold(base, wanted) ||
|
||||||
|
strings.EqualFold(strings.TrimSuffix(base, path.Ext(base)), strings.TrimSuffix(wanted, path.Ext(wanted))) {
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("carrier bundle %q not found; choices: %s", wanted, strings.Join(roots, ", "))
|
||||||
|
}
|
||||||
|
if len(roots) != 1 {
|
||||||
|
return "", fmt.Errorf("IPCC contains multiple carrier bundles; select one with --bundle: %s", strings.Join(roots, ", "))
|
||||||
|
}
|
||||||
|
return roots[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCarrierBundlePlists(files []*zip.File, root string) ([]ipccPlist, error) {
|
||||||
|
var primary *zip.File
|
||||||
|
overrides := make([]*zip.File, 0)
|
||||||
|
rootPrefix := strings.TrimSuffix(root, "/") + "/"
|
||||||
|
for _, file := range files {
|
||||||
|
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
||||||
|
if !strings.HasPrefix(name, rootPrefix) || strings.Contains(strings.ToLower(name), "/signatures/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := path.Base(name)
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(name, rootPrefix+"carrier.plist"):
|
||||||
|
primary = file
|
||||||
|
case strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist"):
|
||||||
|
overrides = append(overrides, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if primary == nil {
|
||||||
|
return nil, fmt.Errorf("bundle %q has no carrier.plist", root)
|
||||||
|
}
|
||||||
|
sort.Slice(overrides, func(i, j int) bool { return overrides[i].Name < overrides[j].Name })
|
||||||
|
selected := append([]*zip.File{primary}, overrides...)
|
||||||
|
result := make([]ipccPlist, 0, len(selected))
|
||||||
|
var total uint64
|
||||||
|
for _, file := range selected {
|
||||||
|
if file.UncompressedSize64 > maxIPCCPlistBytes {
|
||||||
|
return nil, fmt.Errorf("plist %q exceeds %d bytes", file.Name, maxIPCCPlistBytes)
|
||||||
|
}
|
||||||
|
total += file.UncompressedSize64
|
||||||
|
if total > maxIPCCPlistTotalBytes {
|
||||||
|
return nil, fmt.Errorf("selected plists exceed %d uncompressed bytes", maxIPCCPlistTotalBytes)
|
||||||
|
}
|
||||||
|
root, err := decodeIPCCPlist(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode plist %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
result = append(result, ipccPlist{name: file.Name, root: root})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeIPCCPlist(file *zip.File) (map[string]any, error) {
|
||||||
|
reader, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
encoded, err := io.ReadAll(io.LimitReader(reader, maxIPCCPlistBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(encoded) > maxIPCCPlistBytes {
|
||||||
|
return nil, fmt.Errorf("plist exceeds %d bytes", maxIPCCPlistBytes)
|
||||||
|
}
|
||||||
|
decoder := plist.NewDecoder(bytes.NewReader(encoded))
|
||||||
|
var root map[string]any
|
||||||
|
if err := decoder.Decode(&root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if root == nil {
|
||||||
|
return nil, errors.New("plist root is not a dictionary")
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importCarrierSelectors(primary map[string]any, plists []ipccPlist, warnings *ipccWarningSet) ([]carrierProfileMatch, []string, error) {
|
||||||
|
supportedSIMs := plistStrings(primary["SupportedSIMs"])
|
||||||
|
supportedPLMNs := normalizedPLMNs(plistStrings(primary["SupportedPLMNs"]))
|
||||||
|
plainPLMNs := make([]string, 0)
|
||||||
|
qualified := make([]carrierProfileMatch, 0)
|
||||||
|
for _, raw := range supportedSIMs {
|
||||||
|
match, constrained, valid := parseAppleSupportedSIM(raw, warnings)
|
||||||
|
if !valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if constrained {
|
||||||
|
qualified = append(qualified, match)
|
||||||
|
} else {
|
||||||
|
plainPLMNs = append(plainPLMNs, match.HomePLMNs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allPLMNs := normalizeIPCCStringList(append(append([]string(nil), plainPLMNs...), supportedPLMNs...), false)
|
||||||
|
matches := qualified
|
||||||
|
if len(matches) == 0 {
|
||||||
|
if len(allPLMNs) == 0 {
|
||||||
|
return nil, nil, errors.New("no supported MCC/MNC selector was found")
|
||||||
|
}
|
||||||
|
match := carrierProfileMatch{HomePLMNs: allPLMNs}
|
||||||
|
iccidPrefixes := collectMatchingICCIDPrefixes(plists)
|
||||||
|
if len(iccidPrefixes) > 0 {
|
||||||
|
match.ICCIDPrefixes = iccidPrefixes
|
||||||
|
warnings.add(
|
||||||
|
"remote_provisioning_iccid_selector",
|
||||||
|
"MatchingICCIDPrefixes was used only because the bundle has no GID/SPN selector; verify that it identifies subscriptions rather than only eSIM provisioning eligibility",
|
||||||
|
"RemoteCardProvisioningSettings.MatchingICCIDPrefixes",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
warnings.add(
|
||||||
|
"broad_plmn_selector",
|
||||||
|
"the generated rule matches a whole home PLMN because the bundle exposes no GID, SPN, or ICCID discriminator",
|
||||||
|
"SupportedSIMs",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
matches = []carrierProfileMatch{match}
|
||||||
|
}
|
||||||
|
matches = deduplicateCarrierMatches(matches)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, nil, errors.New("all SupportedSIMs selectors were unsupported")
|
||||||
|
}
|
||||||
|
if len(allPLMNs) == 0 {
|
||||||
|
for _, match := range matches {
|
||||||
|
allPLMNs = append(allPLMNs, match.HomePLMNs...)
|
||||||
|
}
|
||||||
|
allPLMNs = normalizeIPCCStringList(allPLMNs, false)
|
||||||
|
}
|
||||||
|
return matches, allPLMNs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfileMatch, bool, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
parts := strings.Split(raw, "_")
|
||||||
|
if len(parts) == 0 || !supportedSIMPLMN.MatchString(parts[0]) || canonicalPLMNValue(parts[0]) == "" {
|
||||||
|
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs value "+strconv.Quote(raw), "SupportedSIMs")
|
||||||
|
return carrierProfileMatch{}, false, false
|
||||||
|
}
|
||||||
|
match := carrierProfileMatch{HomePLMNs: []string{parts[0]}}
|
||||||
|
for _, qualifier := range parts[1:] {
|
||||||
|
name, value, found := strings.Cut(qualifier, "-")
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if !found || value == "" {
|
||||||
|
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(qualifier), "SupportedSIMs")
|
||||||
|
return carrierProfileMatch{}, false, false
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||||
|
case "GID1":
|
||||||
|
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value))
|
||||||
|
case "GID2":
|
||||||
|
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value))
|
||||||
|
case "ICCID":
|
||||||
|
match.ICCIDPrefixes = append(match.ICCIDPrefixes, strings.TrimRight(value, "Ff"))
|
||||||
|
case "SPN":
|
||||||
|
match.SPNs = append(match.SPNs, value)
|
||||||
|
default:
|
||||||
|
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(name), "SupportedSIMs")
|
||||||
|
return carrierProfileMatch{}, false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match, len(parts) > 1, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimAppleHexMask(value string) string {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
trimmed := strings.TrimRight(value, "F")
|
||||||
|
if trimmed == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
||||||
|
values := make([]string, 0)
|
||||||
|
for _, document := range plists {
|
||||||
|
walkPlist(document.root, nil, func(path []string, value any) {
|
||||||
|
if len(path) == 0 || !strings.EqualFold(path[len(path)-1], "MatchingICCIDPrefixes") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, prefix := range plistStrings(value) {
|
||||||
|
prefix = strings.TrimRight(strings.TrimSpace(prefix), "Ff")
|
||||||
|
if len(prefix) >= 5 && decimalString(prefix) {
|
||||||
|
values = append(values, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return normalizeIPCCStringList(values, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importCarrierEPDG(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||||
|
addresses := make(map[string][]string)
|
||||||
|
for _, document := range plists {
|
||||||
|
for _, ike := range dictionariesForKey(document.root, "IKE") {
|
||||||
|
address := strings.ToLower(strings.TrimSuffix(plistString(ike.value["RemoteAddress"]), "."))
|
||||||
|
if address == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !validEPDGHostname(address) {
|
||||||
|
warnings.add("unsupported_epdg_address", "ignored non-ePDG IKE RemoteAddress "+strconv.Quote(address), document.name+":"+strings.Join(ike.path, "."))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addresses[address] = append(addresses[address], document.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := sortedMapKeys(addresses)
|
||||||
|
switch len(keys) {
|
||||||
|
case 0:
|
||||||
|
warnings.add("epdg_not_explicit", "no unambiguous ePDG RemoteAddress was found; VoCat will derive the standard 3GPP hostname from the matched PLMN", "TechSettings.IKE.RemoteAddress")
|
||||||
|
case 1:
|
||||||
|
rule.EPDG.Hostname = keys[0]
|
||||||
|
default:
|
||||||
|
warnings.add("conflicting_epdg", "device override plists disagree on ePDG RemoteAddress; no address was imported: "+strings.Join(keys, ", "), "TechSettings.IKE.RemoteAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importCarrierIKE(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||||
|
groups := make(map[int]struct{})
|
||||||
|
eapMethods := make(map[string]struct{})
|
||||||
|
for _, document := range plists {
|
||||||
|
for _, located := range dictionariesForKey(document.root, "IKE") {
|
||||||
|
ike := located.value
|
||||||
|
for _, proposal := range plistDictionaries(ike["Proposals"]) {
|
||||||
|
if group, ok := plistInt(proposal["DHGroup"]); ok {
|
||||||
|
groups[group] = struct{}{}
|
||||||
|
}
|
||||||
|
if method := strings.ToUpper(plistString(proposal["EAPMethod"])); method != "" {
|
||||||
|
eapMethods[method] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validate, ok := plistBool(ike["ValidateRemoteCertificate"]); ok && !validate {
|
||||||
|
warnings.add("remote_certificate_bypass_ignored", "ValidateRemoteCertificate=false was not imported", document.name+":"+strings.Join(located.path, ".")+".ValidateRemoteCertificate")
|
||||||
|
}
|
||||||
|
if enabled, ok := plistBool(ike["DeadPeerDetectionEnabled"]); ok {
|
||||||
|
if !enabled {
|
||||||
|
warnings.add("disabled_dpd_ignored", "Apple disables DPD for this device family; VoCat keeps its safe liveness defaults", document.name+":"+strings.Join(located.path, ".")+".DeadPeerDetectionEnabled")
|
||||||
|
} else if _, hasInterval := ike["DeadPeerDetectionInterval"]; hasInterval {
|
||||||
|
warnings.add("dpd_override_ignored", "device-specific DPD timing was not imported; VoCat keeps its runtime defaults", document.name+":"+strings.Join(located.path, "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(groups) > 0 {
|
||||||
|
unknown := make([]string, 0)
|
||||||
|
_, hasModern := groups[14]
|
||||||
|
_, hasLegacy := groups[2]
|
||||||
|
for group := range groups {
|
||||||
|
if group != 2 && group != 14 {
|
||||||
|
unknown = append(unknown, strconv.Itoa(group))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(unknown)
|
||||||
|
switch {
|
||||||
|
case len(unknown) > 0:
|
||||||
|
warnings.add("unsupported_ike_group", "unsupported IKE DH group(s) were not imported: "+strings.Join(unknown, ", "), "TechSettings.IKE.Proposals")
|
||||||
|
case hasModern:
|
||||||
|
rule.IKE.Proposal = IKEProposalModern
|
||||||
|
case hasLegacy:
|
||||||
|
rule.IKE.Proposal = IKEProposalLegacy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for method := range eapMethods {
|
||||||
|
if method != "EAP-AKA" && method != "EAP-AKA'" {
|
||||||
|
warnings.add("unsupported_eap_method", "VoCat does not import Apple EAP method "+strconv.Quote(method), "TechSettings.IKE.Proposals.EAPMethod")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||||
|
useIPSec := false
|
||||||
|
for _, document := range plists {
|
||||||
|
for _, signaling := range dictionariesForKey(document.root, "Signaling") {
|
||||||
|
if value, ok := plistBool(signaling.value["UseIPSec"]); ok {
|
||||||
|
if value {
|
||||||
|
useIPSec = true
|
||||||
|
} else {
|
||||||
|
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if useIPSec {
|
||||||
|
// Apple does not describe the negotiated ESP algorithm in a portable
|
||||||
|
// field. Keep VoCat's safe AES-CBC default while recording the intent.
|
||||||
|
rule.IMS.IPSecEncryption = "aes-cbc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
||||||
|
for _, document := range plists {
|
||||||
|
walkPlist(document.root, nil, func(keyPath []string, value any) {
|
||||||
|
if len(keyPath) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.ToLower(keyPath[len(keyPath)-1])
|
||||||
|
fullPath := document.name + ":" + strings.Join(keyPath, ".")
|
||||||
|
switch {
|
||||||
|
case key == "enablewificallingwithoutentitlement":
|
||||||
|
if enabled, ok := plistBool(value); ok && enabled {
|
||||||
|
warnings.add("entitlement_bypass_ignored", "Wi-Fi Calling entitlement bypass was not imported", fullPath)
|
||||||
|
}
|
||||||
|
case key == "apns":
|
||||||
|
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
||||||
|
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
||||||
|
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
||||||
|
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
||||||
|
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type locatedDictionary struct {
|
||||||
|
path []string
|
||||||
|
value map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func dictionariesForKey(root map[string]any, wanted string) []locatedDictionary {
|
||||||
|
result := make([]locatedDictionary, 0)
|
||||||
|
walkPlist(root, nil, func(keyPath []string, value any) {
|
||||||
|
if len(keyPath) == 0 || !strings.EqualFold(keyPath[len(keyPath)-1], wanted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dictionary, ok := value.(map[string]any); ok {
|
||||||
|
result = append(result, locatedDictionary{path: append([]string(nil), keyPath...), value: dictionary})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkPlist(value any, keyPath []string, visit func([]string, any)) {
|
||||||
|
visit(keyPath, value)
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
keys := make([]string, 0, len(typed))
|
||||||
|
for key := range typed {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
walkPlist(typed[key], appendPath(keyPath, key), visit)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for index, item := range typed {
|
||||||
|
walkPlist(item, appendPath(keyPath, strconv.Itoa(index)), visit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPath(base []string, item string) []string {
|
||||||
|
result := make([]string, len(base), len(base)+1)
|
||||||
|
copy(result, base)
|
||||||
|
return append(result, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func plistStrings(value any) []string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(typed) != "" {
|
||||||
|
return []string{strings.TrimSpace(typed)}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
result := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if value := plistString(item); value != "" {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case []string:
|
||||||
|
return normalizeIPCCStringList(typed, false)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIPCCStringList(values []string, lower bool) []string {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if lower {
|
||||||
|
value = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[value]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func plistDictionaries(value any) []map[string]any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return []map[string]any{typed}
|
||||||
|
case []any:
|
||||||
|
result := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if dictionary, ok := item.(map[string]any); ok {
|
||||||
|
result = append(result, dictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plistString(value any) string {
|
||||||
|
if text, ok := value.(string); ok {
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func plistBool(value any) (bool, bool) {
|
||||||
|
result, ok := value.(bool)
|
||||||
|
return result, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func plistInt(value any) (int, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case int:
|
||||||
|
return typed, true
|
||||||
|
case int64:
|
||||||
|
return int(typed), int64(int(typed)) == typed
|
||||||
|
case uint64:
|
||||||
|
return int(typed), uint64(int(typed)) == typed
|
||||||
|
case float64:
|
||||||
|
return int(typed), float64(int(typed)) == typed
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedPLMNs(values []string) []string {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if supportedSIMPLMN.MatchString(value) && canonicalPLMNValue(value) != "" {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeIPCCStringList(result, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicateCarrierMatches(matches []carrierProfileMatch) []carrierProfileMatch {
|
||||||
|
result := make([]carrierProfileMatch, 0, len(matches))
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, match := range matches {
|
||||||
|
encoded, _ := json.Marshal(match)
|
||||||
|
key := string(encoded)
|
||||||
|
if _, duplicate := seen[key]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
result = append(result, match)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusBarCarrierName(root map[string]any) string {
|
||||||
|
for _, item := range plistDictionaries(root["StatusBarImages"]) {
|
||||||
|
if name := firstNonempty(plistString(item["CarrierName"]), plistString(item["StatusBarCarrierName"])); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedIPCCProfileID(carrierName string, plmns []string) string {
|
||||||
|
base := slugCarrierProfileID(carrierName)
|
||||||
|
if base == "" {
|
||||||
|
base = "carrier"
|
||||||
|
}
|
||||||
|
if len(plmns) > 0 {
|
||||||
|
base += "-" + plmns[0]
|
||||||
|
}
|
||||||
|
base = "ipcc-" + base
|
||||||
|
if len(base) > 64 {
|
||||||
|
base = strings.TrimRight(base[:64], "-._")
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func slugCarrierProfileID(value string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
separator := false
|
||||||
|
for _, item := range strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
switch {
|
||||||
|
case item >= 'a' && item <= 'z', item >= '0' && item <= '9':
|
||||||
|
if separator && result.Len() > 0 {
|
||||||
|
result.WriteByte('-')
|
||||||
|
}
|
||||||
|
result.WriteRune(item)
|
||||||
|
separator = false
|
||||||
|
case unicode.IsSpace(item), item == '-', item == '_', item == '.':
|
||||||
|
separator = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(result.String(), "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInstalledProfileID(value string) bool {
|
||||||
|
if len(value) < 1 || len(value) > 64 || !asciiLowerOrDigit(rune(value[0])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range value {
|
||||||
|
if asciiLowerOrDigit(item) || item == '-' || item == '_' || item == '.' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func asciiLowerOrDigit(item rune) bool {
|
||||||
|
return item >= 'a' && item <= 'z' || item >= '0' && item <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEPDGHostname(value string) bool {
|
||||||
|
if len(value) < 4 || len(value) > 253 || !strings.Contains(strings.ToLower(value), "epdg") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, label := range strings.Split(value, ".") {
|
||||||
|
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range label {
|
||||||
|
if item >= 'a' && item <= 'z' || item >= '0' && item <= '9' || item == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func consensusPositiveInt(values []int) (int, bool) {
|
||||||
|
if len(values) == 0 || values[0] <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for _, value := range values[1:] {
|
||||||
|
if value != values[0] {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedMapKeys[T any](values map[string]T) []string {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonempty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value = strings.TrimSpace(value); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package vowifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"howett.net/plist"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testIPCCPlist struct {
|
||||||
|
value map[string]any
|
||||||
|
format int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
||||||
|
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||||
|
"Payload/O2_Giffgaff_UK.bundle/carrier.plist": {
|
||||||
|
format: plist.XMLFormat,
|
||||||
|
value: map[string]any{
|
||||||
|
"CarrierName": "giffgaff",
|
||||||
|
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
||||||
|
"SupportedPLMNs": []any{"23410"},
|
||||||
|
"apns": []any{map[string]any{"apn": "giffgaff.com"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Payload/O2_Giffgaff_UK.bundle/overrides_D1.plist": {
|
||||||
|
format: plist.BinaryFormat,
|
||||||
|
value: map[string]any{
|
||||||
|
"TechSettings": map[string]any{
|
||||||
|
"IKE": map[string]any{
|
||||||
|
"RemoteAddress": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org",
|
||||||
|
"ValidateRemoteCertificate": false,
|
||||||
|
"DeadPeerDetectionEnabled": false,
|
||||||
|
"Proposals": []any{map[string]any{
|
||||||
|
"DHGroup": 14, "EAPMethod": "EAP-AKA",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"IMSConfig": map[string]any{
|
||||||
|
"EnableWiFiCallingWithoutEntitlement": true,
|
||||||
|
"Signaling": map[string]any{"UseIPSec": true},
|
||||||
|
"Media": map[string]any{"SupportPCMA": false},
|
||||||
|
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.CarrierName != "giffgaff" || result.ProfileID != "ipcc-giffgaff-23410" || result.SourceSHA256 == "" {
|
||||||
|
t.Fatalf("import metadata = %#v", result)
|
||||||
|
}
|
||||||
|
var document carrierProfileDocument
|
||||||
|
if err := json.Unmarshal(result.Document, &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if document.Version != CarrierProfileSchemaVersion || len(document.Profiles) != 1 {
|
||||||
|
t.Fatalf("document = %#v", document)
|
||||||
|
}
|
||||||
|
rule := document.Profiles[0]
|
||||||
|
if rule.Match.HomePLMNs[0] != "23410" || rule.Match.GID1Prefixes[0] != "508" {
|
||||||
|
t.Fatalf("converted selector = %#v", rule.Match)
|
||||||
|
}
|
||||||
|
if rule.EPDG.Hostname != "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org" || rule.IKE.Proposal != IKEProposalModern {
|
||||||
|
t.Fatalf("converted IKE profile = %#v", rule)
|
||||||
|
}
|
||||||
|
if rule.IMS.IPSecEncryption != "aes-cbc" {
|
||||||
|
t.Fatalf("converted IMS profile = %#v", rule.IMS)
|
||||||
|
}
|
||||||
|
for _, code := range []string{
|
||||||
|
"remote_certificate_bypass_ignored",
|
||||||
|
"disabled_dpd_ignored",
|
||||||
|
"entitlement_bypass_ignored",
|
||||||
|
"apn_settings_ignored",
|
||||||
|
"device_media_overrides_ignored",
|
||||||
|
"emergency_settings_ignored",
|
||||||
|
} {
|
||||||
|
if !hasIPCCWarning(result.Warnings, code) {
|
||||||
|
t.Errorf("missing warning %q: %#v", code, result.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportCarrierIPCCRejectsAmbiguousBundleAndConflictingEPDG(t *testing.T) {
|
||||||
|
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||||
|
"Payload/One.bundle/carrier.plist": {
|
||||||
|
format: plist.XMLFormat,
|
||||||
|
value: map[string]any{"CarrierName": "One", "SupportedSIMs": []any{"99901"}},
|
||||||
|
},
|
||||||
|
"Payload/One.bundle/overrides_A.plist": {
|
||||||
|
format: plist.XMLFormat,
|
||||||
|
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.one.example"}}},
|
||||||
|
},
|
||||||
|
"Payload/One.bundle/overrides_B.plist": {
|
||||||
|
format: plist.BinaryFormat,
|
||||||
|
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.two.example"}}},
|
||||||
|
},
|
||||||
|
"Payload/Two.bundle/carrier.plist": {
|
||||||
|
format: plist.BinaryFormat,
|
||||||
|
value: map[string]any{"CarrierName": "Two", "SupportedSIMs": []any{"99902"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{}); err == nil {
|
||||||
|
t.Fatal("multi-bundle IPCC imported without --bundle")
|
||||||
|
}
|
||||||
|
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{Bundle: "One"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var document carrierProfileDocument
|
||||||
|
if err := json.Unmarshal(result.Document, &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if document.Profiles[0].EPDG.Hostname != "" || !hasIPCCWarning(result.Warnings, "conflicting_epdg") {
|
||||||
|
t.Fatalf("conflicting ePDG was not quarantined: %#v, %#v", document.Profiles[0], result.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallCarrierIPCCResultLoadsExternalProfileAtEqualSpecificity(t *testing.T) {
|
||||||
|
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||||
|
"Payload/Test.bundle/carrier.plist": {
|
||||||
|
format: plist.BinaryFormat,
|
||||||
|
value: map[string]any{
|
||||||
|
"CarrierName": "Installed Test",
|
||||||
|
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
||||||
|
"SupportedPLMNs": []any{"23410"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{ProfileID: "installed-giffgaff-test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
emptyDir := t.TempDir()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := LoadCarrierProfileDirectory(emptyDir); err != nil {
|
||||||
|
t.Errorf("clear external profiles: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
target, err := InstallCarrierIPCCResult(result, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if filepath.Base(target) != "installed-giffgaff-test.json" {
|
||||||
|
t.Fatalf("installed path = %q", target)
|
||||||
|
}
|
||||||
|
if _, err := InstallCarrierIPCCResult(result, dir); err == nil {
|
||||||
|
t.Fatal("second install overwrote an existing profile")
|
||||||
|
}
|
||||||
|
if err := LoadCarrierProfileDirectory(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"})
|
||||||
|
if profile.ID != "installed-giffgaff-test" {
|
||||||
|
t.Fatalf("installed equal-specificity profile did not override builtin: %#v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestIPCC(t *testing.T, files map[string]testIPCCPlist) string {
|
||||||
|
t.Helper()
|
||||||
|
archivePath := filepath.Join(t.TempDir(), "carrier.ipcc")
|
||||||
|
file, err := os.Create(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := zip.NewWriter(file)
|
||||||
|
for name, item := range files {
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := plist.NewEncoderForFormat(&encoded, item.format).Encode(item.value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, err := archive.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return archivePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasIPCCWarning(warnings []IPCCImportWarning, code string) bool {
|
||||||
|
for _, warning := range warnings {
|
||||||
|
if warning.Code == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -12,13 +12,19 @@
|
|||||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ctexcel-ee-uk",
|
"id": "ee-uk-hosted-23433",
|
||||||
"match": {
|
"match": {
|
||||||
"home_plmns": ["23433"],
|
"home_plmns": ["23433"],
|
||||||
"imsi_prefixes": ["23433"],
|
"imsi_prefixes": ["23433"],
|
||||||
"iccid_prefixes": ["894430"]
|
"iccid_prefixes": ["894430"]
|
||||||
},
|
},
|
||||||
"route": { "mcc": "234", "mnc": "30" }
|
"route": { "mcc": "234", "mnc": "30" },
|
||||||
|
"ims": {
|
||||||
|
"pani_country": "GB",
|
||||||
|
"pani_node": "000000000000",
|
||||||
|
"dial_uri_scheme": "sip",
|
||||||
|
"voice_codecs": ["PCMA", "PCMU"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "att-us",
|
"id": "att-us",
|
||||||
@@ -30,7 +36,22 @@
|
|||||||
"ims": {
|
"ims": {
|
||||||
"identity_profile": "att",
|
"identity_profile": "att",
|
||||||
"register_profile": "att",
|
"register_profile": "att",
|
||||||
"ipsec_encryption": "aes-cbc"
|
"ipsec_encryption": "aes-cbc",
|
||||||
|
"register_options": {
|
||||||
|
"contact_format": "att",
|
||||||
|
"expiry_seconds": 18400,
|
||||||
|
"contact_extra_tags": ["+g.3gpp.accesstype=\"wlan1\""],
|
||||||
|
"supported_header": "path,sec-agree,gruu",
|
||||||
|
"user_agent": "SimAdmin VoWiFi",
|
||||||
|
"p_preferred_identity": true,
|
||||||
|
"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_tags": [
|
||||||
|
"*;+g.3gpp.smsip",
|
||||||
|
"*;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\""
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -39,7 +60,12 @@
|
|||||||
"ike": { "advertise_eap_only": false },
|
"ike": { "advertise_eap_only": false },
|
||||||
"ims": {
|
"ims": {
|
||||||
"register_profile": "o2-germany",
|
"register_profile": "o2-germany",
|
||||||
"ipsec_encryption": "null"
|
"ipsec_encryption": "null",
|
||||||
|
"register_options": {
|
||||||
|
"supported_header": "path, gruu, outbound, sec-agree, 100rel, timer",
|
||||||
|
"allow_header": "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS",
|
||||||
|
"p_preferred_identity": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,12 +79,31 @@
|
|||||||
"match": { "home_plmns": ["20404"] },
|
"match": { "home_plmns": ["20404"] },
|
||||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "giffgaff-o2-uk",
|
||||||
|
"match_any": [
|
||||||
|
{
|
||||||
|
"home_plmns": ["23410"],
|
||||||
|
"gid1_prefixes": ["508"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"home_plmns": ["23410"],
|
||||||
|
"spns": ["giffgaff"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ims": {
|
||||||
|
"transport": "udp",
|
||||||
|
"sms_center": "+447802002606",
|
||||||
|
"user_eq_phone": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "o2-uk",
|
"id": "o2-uk",
|
||||||
"match": { "home_plmns": ["23410"] },
|
"match": { "home_plmns": ["23410"] },
|
||||||
"ims": {
|
"ims": {
|
||||||
"transport": "udp",
|
"transport": "udp",
|
||||||
"sms_center": "+447802000332"
|
"sms_center": "+447802000332",
|
||||||
|
"user_eq_phone": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,6 +113,14 @@
|
|||||||
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
|
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
|
||||||
"dns_client_subnet": "109.192.0.0/24"
|
"dns_client_subnet": "109.192.0.0/24"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dito-philippines",
|
||||||
|
"match": { "home_plmns": ["51566"] },
|
||||||
|
"ike": { "proposal": "legacy-sha1-modp1024" },
|
||||||
|
"ims": {
|
||||||
|
"allow_sms_without_contact_confirmation": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ var (
|
|||||||
|
|
||||||
const terminalCallRetention = 30 * time.Second
|
const terminalCallRetention = 30 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
mmtelServiceURN = "urn:urn-7:3gpp-service.ims.icsi.mmtel"
|
||||||
|
mmtelFeatureTag = "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"
|
||||||
|
)
|
||||||
|
|
||||||
type imsCall struct {
|
type imsCall struct {
|
||||||
public vowifi.Call
|
public vowifi.Call
|
||||||
callID string
|
callID string
|
||||||
@@ -28,6 +33,7 @@ type imsCall struct {
|
|||||||
to string
|
to string
|
||||||
branch string
|
branch string
|
||||||
cseq uint32
|
cseq uint32
|
||||||
|
inviteTarget string
|
||||||
invite *sipRequest
|
invite *sipRequest
|
||||||
respond func([]byte) error
|
respond func([]byte) error
|
||||||
responses chan *sipResponse
|
responses chan *sipResponse
|
||||||
@@ -35,6 +41,9 @@ type imsCall struct {
|
|||||||
routes []string
|
routes []string
|
||||||
terminated bool
|
terminated bool
|
||||||
media *rtpMedia
|
media *rtpMedia
|
||||||
|
pracked map[string]bool
|
||||||
|
sessionExpires int
|
||||||
|
sessionCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) Calls() []vowifi.Call {
|
func (session *Session) Calls() []vowifi.Call {
|
||||||
@@ -67,12 +76,14 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
callID := callToken + "@" + addressHost(session.conn.LocalAddr())
|
callID := callToken + "@" + addressHost(session.conn.LocalAddr())
|
||||||
target := "tel:" + number
|
carrierProfile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||||
|
target := callTargetURI(number, session.identity.domain, carrierProfile)
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
cseq := session.cseq
|
cseq := session.cseq
|
||||||
session.cseq++
|
session.cseq++
|
||||||
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
||||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
fromIdentity, preferredIdentity, identitySource := session.callOriginatingIdentitiesLocked(carrierProfile)
|
||||||
session.mu.Unlock()
|
session.mu.Unlock()
|
||||||
media, err := newRTPMedia(session.localMediaIP())
|
media, err := newRTPMedia(session.localMediaIP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,7 +91,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
}
|
}
|
||||||
body := media.offerSDP(session.localMediaIP())
|
body := media.offerSDP(session.localMediaIP())
|
||||||
transportUpper := strings.ToUpper(session.transport)
|
transportUpper := strings.ToUpper(session.transport)
|
||||||
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
from := "<" + fromIdentity + ">;tag=" + session.fromTag
|
||||||
to := "<" + target + ">"
|
to := "<" + target + ">"
|
||||||
lines := []string{
|
lines := []string{
|
||||||
"INVITE " + target + " SIP/2.0",
|
"INVITE " + target + " SIP/2.0",
|
||||||
@@ -100,10 +111,17 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
"To: "+to,
|
"To: "+to,
|
||||||
"Call-ID: "+callID,
|
"Call-ID: "+callID,
|
||||||
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
||||||
"Contact: <sip:"+session.identity.user+"@"+session.contactAddress()+";transport="+session.transport+">",
|
session.dialogContactHeader(),
|
||||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
"P-Preferred-Identity: <"+preferredIdentity+">",
|
||||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE",
|
"P-Preferred-Service: "+mmtelServiceURN,
|
||||||
"Supported: timer",
|
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
|
||||||
|
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
|
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
|
||||||
|
"Supported: 100rel, timer, replaces",
|
||||||
|
"Session-Expires: 1800;refresher=uac",
|
||||||
|
"Min-SE: 90",
|
||||||
|
"Accept: application/sdp",
|
||||||
"Content-Type: application/sdp",
|
"Content-Type: application/sdp",
|
||||||
"Content-Length: "+strconv.Itoa(len(body)), "", "",
|
"Content-Length: "+strconv.Itoa(len(body)), "", "",
|
||||||
)
|
)
|
||||||
@@ -120,12 +138,19 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
session.transactionsMu.Unlock()
|
session.transactionsMu.Unlock()
|
||||||
call := &imsCall{
|
call := &imsCall{
|
||||||
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
||||||
callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
callID: callID, target: target, inviteTarget: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||||
routes: routes, media: media,
|
routes: routes, media: media, pracked: make(map[string]bool),
|
||||||
}
|
}
|
||||||
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.Logger != nil {
|
||||||
|
session.provider.config.Logger.Info("IMS call started",
|
||||||
|
"direction", "outgoing",
|
||||||
|
"identity_source", identitySource,
|
||||||
|
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||||
|
)
|
||||||
|
}
|
||||||
session.writeMu.Lock()
|
session.writeMu.Lock()
|
||||||
_, err = session.conn.Write(request)
|
_, err = session.conn.Write(request)
|
||||||
session.writeMu.Unlock()
|
session.writeMu.Unlock()
|
||||||
@@ -166,24 +191,26 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
|||||||
if response == nil {
|
if response == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
diagnostic := callResponseDiagnostic(response)
|
||||||
|
session.logCallResponse(response, diagnostic)
|
||||||
if response.StatusCode < 200 {
|
if response.StatusCode < 200 {
|
||||||
session.setCallDiagnostic(call.callID, response.StatusCode, response.Reason)
|
session.setCallDiagnostic(call.callID, response.StatusCode, diagnostic)
|
||||||
if response.StatusCode >= 180 {
|
session.updateCallDialogFromResponse(call, response)
|
||||||
|
if len(response.Body) > 0 {
|
||||||
|
if mediaErr := call.media.configureRemote(response.Body); mediaErr == nil {
|
||||||
|
session.setCallMediaReady(call.callID)
|
||||||
|
session.setCallState(call.callID, "early_media")
|
||||||
|
}
|
||||||
|
} else if response.StatusCode >= 180 {
|
||||||
session.setCallState(call.callID, "ringing")
|
session.setCallState(call.callID, "ringing")
|
||||||
}
|
}
|
||||||
|
if reliableProvisional(response) {
|
||||||
|
go session.sendPRACK(call, response)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||||
session.callMu.Lock()
|
session.updateCallDialogFromResponse(call, response)
|
||||||
call.to = response.value("To")
|
|
||||||
call.remoteTag = headerParameter(call.to, "tag")
|
|
||||||
if contact := headerURI(response.value("Contact")); contact != "" {
|
|
||||||
call.target = contact
|
|
||||||
}
|
|
||||||
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
|
||||||
call.routes = reverseStrings(recordRoutes)
|
|
||||||
}
|
|
||||||
session.callMu.Unlock()
|
|
||||||
mediaErr := call.media.configureRemote(response.Body)
|
mediaErr := call.media.configureRemote(response.Body)
|
||||||
_ = session.sendACK(call)
|
_ = session.sendACK(call)
|
||||||
if mediaErr != nil {
|
if mediaErr != nil {
|
||||||
@@ -197,13 +224,23 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
|||||||
}
|
}
|
||||||
session.setCallMediaReady(call.callID)
|
session.setCallMediaReady(call.callID)
|
||||||
session.setCallState(call.callID, "active")
|
session.setCallState(call.callID, "active")
|
||||||
} else if session.callWasTerminated(call.callID) {
|
session.startSessionTimer(call, response.value("Session-Expires"))
|
||||||
|
} else {
|
||||||
|
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||||
|
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||||
|
"sip_status", response.StatusCode,
|
||||||
|
"error", safeSIPDiagnostic(ackErr.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if session.callWasTerminated(call.callID) {
|
||||||
// CANCEL normally causes the pending INVITE transaction to finish
|
// CANCEL normally causes the pending INVITE transaction to finish
|
||||||
// with 487 Request Terminated. It is the expected response to our
|
// with 487 Request Terminated. It is the expected response to our
|
||||||
// local hang-up, not a new network rejection.
|
// local hang-up, not a new network rejection.
|
||||||
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
|
session.finishCall(call.callID, "ended", response.StatusCode, diagnostic)
|
||||||
} else {
|
} else {
|
||||||
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
|
session.finishCall(call.callID, "failed", response.StatusCode, diagnostic)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,7 +260,13 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
|||||||
}
|
}
|
||||||
request, respond := call.invite, call.respond
|
request, respond := call.invite, call.respond
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, call.media.answerSDP(session.localMediaIP()))
|
response, err := buildSIPResponseWithBody(
|
||||||
|
request,
|
||||||
|
200,
|
||||||
|
session.fromTag,
|
||||||
|
call.media.answerSDP(session.localMediaIP()),
|
||||||
|
session.dialogContactHeader(),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
@@ -231,6 +274,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
|||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
session.setCallState(id, "active")
|
session.setCallState(id, "active")
|
||||||
|
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||||
if call.media.ready() {
|
if call.media.ready() {
|
||||||
session.setCallMediaReady(id)
|
session.setCallMediaReady(id)
|
||||||
}
|
}
|
||||||
@@ -264,7 +308,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
method := "BYE"
|
method := "BYE"
|
||||||
if direction == "outgoing" && (state == "dialing" || state == "ringing") {
|
if direction == "outgoing" && (state == "dialing" || state == "ringing" || state == "early_media") {
|
||||||
method = "CANCEL"
|
method = "CANCEL"
|
||||||
}
|
}
|
||||||
err := session.sendDialogRequest(ctx, call, method)
|
err := session.sendDialogRequest(ctx, call, method)
|
||||||
@@ -288,6 +332,12 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
if callID == "" {
|
if callID == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
session.callMu.Lock()
|
||||||
|
existing := session.calls[callID]
|
||||||
|
session.callMu.Unlock()
|
||||||
|
if existing != nil && existing.public.State == "active" {
|
||||||
|
return session.handleDialogOffer(request, respond, existing)
|
||||||
|
}
|
||||||
number := identityNumber(request.value("From"))
|
number := identityNumber(request.value("From"))
|
||||||
target := headerURI(request.value("Contact"))
|
target := headerURI(request.value("Contact"))
|
||||||
if target == "" {
|
if target == "" {
|
||||||
@@ -311,8 +361,9 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
}
|
}
|
||||||
call := &imsCall{
|
call := &imsCall{
|
||||||
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
||||||
callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag,
|
callID: callID, target: target, inviteTarget: request.URI, from: request.value("To") + ";tag=" + session.fromTag,
|
||||||
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media,
|
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media,
|
||||||
|
pracked: make(map[string]bool),
|
||||||
}
|
}
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
@@ -322,6 +373,21 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
_ = respond(response)
|
_ = respond(response)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case "PRACK":
|
||||||
|
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil)
|
||||||
|
if err == nil {
|
||||||
|
_ = respond(response)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "UPDATE":
|
||||||
|
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||||
|
session.callMu.Lock()
|
||||||
|
call := session.calls[callID]
|
||||||
|
session.callMu.Unlock()
|
||||||
|
if call == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return session.handleDialogOffer(request, respond, call)
|
||||||
case "ACK":
|
case "ACK":
|
||||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
@@ -366,9 +432,199 @@ func (session *Session) sendACK(call *imsCall) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendRejectedInviteACK acknowledges a non-2xx final response using the
|
||||||
|
// original INVITE transaction branch and request URI. Unlike a 2xx ACK this is
|
||||||
|
// part of the INVITE transaction; sending a dialog-style ACK with a new branch
|
||||||
|
// leaves the P-CSCF retransmitting the rejection and leaking transaction state.
|
||||||
|
func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipResponse) error {
|
||||||
|
if call == nil || response == nil || response.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if session == nil || session.conn == nil {
|
||||||
|
return errors.New("ims: SIP connection unavailable for rejected INVITE ACK")
|
||||||
|
}
|
||||||
|
target := call.inviteTarget
|
||||||
|
if target == "" {
|
||||||
|
target = call.target
|
||||||
|
}
|
||||||
|
to := strings.TrimSpace(response.value("To"))
|
||||||
|
if to == "" {
|
||||||
|
to = call.to
|
||||||
|
}
|
||||||
|
lines := []string{
|
||||||
|
"ACK " + target + " SIP/2.0",
|
||||||
|
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), call.branch),
|
||||||
|
"Max-Forwards: 70",
|
||||||
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
session.mu.Unlock()
|
||||||
|
lines = append(lines, securityHeaders...)
|
||||||
|
for _, route := range call.routes {
|
||||||
|
lines = append(lines, "Route: "+route)
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
"From: "+call.from,
|
||||||
|
"To: "+to,
|
||||||
|
"Call-ID: "+call.callID,
|
||||||
|
fmt.Sprintf("CSeq: %d ACK", call.cseq),
|
||||||
|
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
|
"Content-Length: 0", "", "",
|
||||||
|
)
|
||||||
|
session.writeMu.Lock()
|
||||||
|
_, err := session.conn.Write([]byte(strings.Join(lines, "\r\n")))
|
||||||
|
session.writeMu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func reliableProvisional(response *sipResponse) bool {
|
||||||
|
if response == nil || strings.TrimSpace(response.value("RSeq")) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, token := range strings.Split(strings.ToLower(response.value("Require")), ",") {
|
||||||
|
if strings.TrimSpace(token) == "100rel" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) updateCallDialogFromResponse(call *imsCall, response *sipResponse) {
|
||||||
|
if call == nil || response == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.callMu.Lock()
|
||||||
|
defer session.callMu.Unlock()
|
||||||
|
call.to = response.value("To")
|
||||||
|
call.remoteTag = headerParameter(call.to, "tag")
|
||||||
|
if contact := headerURI(response.value("Contact")); contact != "" {
|
||||||
|
call.target = contact
|
||||||
|
}
|
||||||
|
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
||||||
|
call.routes = reverseStrings(recordRoutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
|
||||||
|
rseq := strings.TrimSpace(response.value("RSeq"))
|
||||||
|
inviteCSeq := strings.TrimSpace(response.value("CSeq"))
|
||||||
|
if rseq == "" || inviteCSeq == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := rseq + "|" + inviteCSeq
|
||||||
|
session.callMu.Lock()
|
||||||
|
if call.pracked == nil {
|
||||||
|
call.pracked = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if call.pracked[key] || call.public.EndedAt != nil {
|
||||||
|
session.callMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call.pracked[key] = true
|
||||||
|
target, to, from := call.target, call.to, call.from
|
||||||
|
routes := append([]string(nil), call.routes...)
|
||||||
|
session.callMu.Unlock()
|
||||||
|
|
||||||
|
session.mu.Lock()
|
||||||
|
cseq := session.cseq
|
||||||
|
session.cseq++
|
||||||
|
session.mu.Unlock()
|
||||||
|
branch, _ := randomHex(12)
|
||||||
|
lines := []string{
|
||||||
|
"PRACK " + target + " SIP/2.0",
|
||||||
|
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||||
|
"Max-Forwards: 70",
|
||||||
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
session.mu.Unlock()
|
||||||
|
lines = append(lines, securityHeaders...)
|
||||||
|
for _, route := range routes {
|
||||||
|
lines = append(lines, "Route: "+route)
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
|
||||||
|
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
|
||||||
|
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
|
"Content-Length: 0", "", "",
|
||||||
|
)
|
||||||
|
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
result, err := session.exchangeRuntime(ctx, []byte(strings.Join(lines, "\r\n")), sipTransactionKey{callID: call.callID, cseq: cseq, method: "PRACK"})
|
||||||
|
if err != nil || result.StatusCode < 200 || result.StatusCode >= 300 {
|
||||||
|
reason := "reliable provisional response could not be acknowledged"
|
||||||
|
if err != nil {
|
||||||
|
reason = err.Error()
|
||||||
|
}
|
||||||
|
session.finishCall(call.callID, "failed", 0, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) handleDialogOffer(request *sipRequest, respond func([]byte) error, call *imsCall) bool {
|
||||||
|
var body []byte
|
||||||
|
if len(request.Body) > 0 {
|
||||||
|
if err := call.media.configureRemote(request.Body); err != nil {
|
||||||
|
if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil {
|
||||||
|
_ = respond(response)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
body = call.media.answerSDP(session.localMediaIP())
|
||||||
|
session.setCallMediaReady(call.callID)
|
||||||
|
}
|
||||||
|
extraHeaders := []string(nil)
|
||||||
|
if request.Method == "INVITE" {
|
||||||
|
extraHeaders = append(extraHeaders, session.dialogContactHeader())
|
||||||
|
}
|
||||||
|
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, body, extraHeaders...)
|
||||||
|
if err == nil {
|
||||||
|
_ = respond(response)
|
||||||
|
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) startSessionTimer(call *imsCall, header string) {
|
||||||
|
value := strings.TrimSpace(strings.Split(header, ";")[0])
|
||||||
|
seconds, err := strconv.Atoi(value)
|
||||||
|
if err != nil || seconds < 90 || seconds > 86400 || call == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(session.refreshContext)
|
||||||
|
session.callMu.Lock()
|
||||||
|
if call.sessionCancel != nil {
|
||||||
|
call.sessionCancel()
|
||||||
|
}
|
||||||
|
call.sessionExpires = seconds
|
||||||
|
call.sessionCancel = cancel
|
||||||
|
session.callMu.Unlock()
|
||||||
|
go func() {
|
||||||
|
interval := time.Duration(seconds) * time.Second / 2
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
refreshContext, cancelRefresh := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
refreshErr := session.sendDialogRequest(refreshContext, call, "UPDATE")
|
||||||
|
cancelRefresh()
|
||||||
|
if refreshErr != nil {
|
||||||
|
session.finishCall(call.callID, "failed", 0, "SIP session refresh failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.Reset(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error {
|
func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error {
|
||||||
cseq := call.cseq
|
cseq := call.cseq
|
||||||
if method == "BYE" {
|
if method == "BYE" || method == "UPDATE" {
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
cseq = session.cseq
|
cseq = session.cseq
|
||||||
session.cseq++
|
session.cseq++
|
||||||
@@ -396,15 +652,23 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
|||||||
if method == "CANCEL" {
|
if method == "CANCEL" {
|
||||||
branch = call.branch
|
branch = call.branch
|
||||||
}
|
}
|
||||||
|
target := call.target
|
||||||
|
if method == "CANCEL" && call.inviteTarget != "" {
|
||||||
|
target = call.inviteTarget
|
||||||
|
}
|
||||||
to := call.to
|
to := call.to
|
||||||
if to == "" {
|
if to == "" {
|
||||||
to = "<" + call.target + ">"
|
to = "<" + call.target + ">"
|
||||||
}
|
}
|
||||||
lines := []string{
|
lines := []string{
|
||||||
method + " " + call.target + " SIP/2.0",
|
method + " " + target + " SIP/2.0",
|
||||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||||
"Max-Forwards: 70",
|
"Max-Forwards: 70",
|
||||||
}
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
session.mu.Unlock()
|
||||||
|
lines = append(lines, securityHeaders...)
|
||||||
for _, route := range call.routes {
|
for _, route := range call.routes {
|
||||||
lines = append(lines, "Route: "+route)
|
lines = append(lines, "Route: "+route)
|
||||||
}
|
}
|
||||||
@@ -413,8 +677,19 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
|||||||
"To: "+to,
|
"To: "+to,
|
||||||
"Call-ID: "+call.callID,
|
"Call-ID: "+call.callID,
|
||||||
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
||||||
"Content-Length: 0", "", "",
|
"Supported: 100rel, timer",
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
)
|
)
|
||||||
|
if method != "CANCEL" {
|
||||||
|
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
|
||||||
|
}
|
||||||
|
if method == "UPDATE" {
|
||||||
|
lines = append(lines, session.dialogContactHeader())
|
||||||
|
}
|
||||||
|
lines = append(lines, "Content-Length: 0", "", "")
|
||||||
|
if method == "UPDATE" && call.sessionExpires > 0 {
|
||||||
|
lines = append(lines[:len(lines)-3], fmt.Sprintf("Session-Expires: %d;refresher=uac", call.sessionExpires), "Content-Length: 0", "", "")
|
||||||
|
}
|
||||||
return []byte(strings.Join(lines, "\r\n"))
|
return []byte(strings.Join(lines, "\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +701,7 @@ func (session *Session) localMediaIP() net.IP {
|
|||||||
return addressIP(localAddress)
|
return addressIP(localAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) {
|
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte, extraHeaders ...string) ([]byte, error) {
|
||||||
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"}
|
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"}
|
||||||
reason := reasons[status]
|
reason := reasons[status]
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
@@ -446,6 +721,14 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
|||||||
lines = append(lines, "Via: "+value)
|
lines = append(lines, "Via: "+value)
|
||||||
}
|
}
|
||||||
lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq)
|
lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq)
|
||||||
|
for _, header := range extraHeaders {
|
||||||
|
if strings.TrimSpace(header) != "" {
|
||||||
|
lines = append(lines, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(request.value("Session-Expires")); value != "" && status >= 200 && status < 300 && (request.Method == "INVITE" || request.Method == "UPDATE") {
|
||||||
|
lines = append(lines, "Supported: timer", "Session-Expires: "+value)
|
||||||
|
}
|
||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
lines = append(lines, "Content-Type: application/sdp")
|
lines = append(lines, "Content-Type: application/sdp")
|
||||||
}
|
}
|
||||||
@@ -453,10 +736,110 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
|||||||
return append([]byte(strings.Join(lines, "\r\n")), body...), nil
|
return append([]byte(strings.Join(lines, "\r\n")), body...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (session *Session) dialogContactHeader() string {
|
||||||
|
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
|
||||||
|
if strings.TrimSpace(session.instanceID) != "" {
|
||||||
|
contact += `;+sip.instance="<` + session.instanceID + `>"`
|
||||||
|
}
|
||||||
|
return contact + `;audio;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTargetURI(number, domain string, profile vowifi.CarrierProfile) string {
|
||||||
|
domain = strings.TrimSpace(domain)
|
||||||
|
if profile.IMSDialURIScheme == "sip" {
|
||||||
|
target := "sip:" + number + "@" + domain
|
||||||
|
if profile.IMSUserEqPhone {
|
||||||
|
target += ";user=phone"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(number, "+") {
|
||||||
|
return "tel:" + number
|
||||||
|
}
|
||||||
|
return "tel:" + number + ";phone-context=" + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// callOriginatingIdentitiesLocked selects only a number that IMS explicitly
|
||||||
|
// associated with this registration. 3GPP originating sessions use that
|
||||||
|
// public identity in both From and P-Preferred-Identity; some TAS deployments
|
||||||
|
// accept an IMSI IMPU at the P-CSCF and then terminate the session immediately.
|
||||||
|
// The fallback deliberately remains the registered IMPU and never derives a
|
||||||
|
// telephone number from IMSI digits.
|
||||||
|
func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierProfile) (from, preferred, source string) {
|
||||||
|
if number, numberSource, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok {
|
||||||
|
from = "sip:" + number + "@" + session.identity.domain
|
||||||
|
if profile.IMSUserEqPhone {
|
||||||
|
from += ";user=phone"
|
||||||
|
}
|
||||||
|
return from, "tel:" + number, numberSource
|
||||||
|
}
|
||||||
|
return session.identity.public, session.identity.public, "registered_impu"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) pAccessNetworkInfo() string {
|
||||||
|
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||||
|
node := strings.TrimSpace(profile.PANINode)
|
||||||
|
if node == "" {
|
||||||
|
node = "000000000000"
|
||||||
|
}
|
||||||
|
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||||
|
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||||
|
value += ";country=" + country
|
||||||
|
}
|
||||||
|
return value + ";network-provided"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) callUserAgent() string {
|
||||||
|
if session != nil && session.provider != nil {
|
||||||
|
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "vocat/1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func callResponseDiagnostic(response *sipResponse) string {
|
||||||
|
if response == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
if reason := safeSIPDiagnostic(response.Reason); reason != "" {
|
||||||
|
parts = append(parts, reason)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Reason", "Warning"} {
|
||||||
|
for _, value := range response.values(name) {
|
||||||
|
if value = safeSIPDiagnostic(value); value != "" {
|
||||||
|
parts = append(parts, name+": "+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safeSIPDiagnostic(strings.Join(parts, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) logCallResponse(response *sipResponse, diagnostic string) {
|
||||||
|
if session == nil || session.provider == nil || session.provider.config.Logger == nil || response == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.provider.config.Logger.Info("IMS call response",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||||
|
"sip_status", response.StatusCode,
|
||||||
|
"diagnostic", diagnostic,
|
||||||
|
"content_type", safeSIPDiagnostic(response.value("Content-Type")),
|
||||||
|
"body_bytes", len(response.Body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) setCallState(id, state string) {
|
func (session *Session) setCallState(id, state string) {
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
if call := session.calls[id]; call != nil {
|
if call := session.calls[id]; call != nil {
|
||||||
call.public.State = state
|
call.public.State = state
|
||||||
|
if state == "active" && call.public.AnsweredAt == nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
call.public.AnsweredAt = &now
|
||||||
|
}
|
||||||
if state != "ended" && state != "failed" {
|
if state != "ended" && state != "failed" {
|
||||||
call.public.EndedAt = nil
|
call.public.EndedAt = nil
|
||||||
}
|
}
|
||||||
@@ -509,6 +892,10 @@ func (session *Session) finishCall(id, state string, code int, reason string) {
|
|||||||
call.public.Reason = reason
|
call.public.Reason = reason
|
||||||
}
|
}
|
||||||
call.public.EndedAt = &now
|
call.public.EndedAt = &now
|
||||||
|
if call.sessionCancel != nil {
|
||||||
|
call.sessionCancel()
|
||||||
|
call.sessionCancel = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
if media != nil {
|
if media != nil {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package ims
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,7 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
||||||
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
client, peer := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer peer.Close()
|
||||||
|
session := &Session{
|
||||||
|
fromTag: "local-tag", calls: make(map[string]*imsCall), conn: client,
|
||||||
|
identity: identitySet{user: "subscriber"}, transport: "udp",
|
||||||
|
}
|
||||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||||
"INVITE sip:[email protected] SIP/2.0",
|
"INVITE sip:[email protected] SIP/2.0",
|
||||||
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming",
|
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming",
|
||||||
@@ -42,6 +50,9 @@ func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
|||||||
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") {
|
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") {
|
||||||
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
|
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(string(responses[1]), "Contact: <sip:subscriber@pipe;transport=udp>") {
|
||||||
|
t.Fatalf("answer omitted dialog Contact: %q", responses[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIncomingCallCanBeRejected(t *testing.T) {
|
func TestIncomingCallCanBeRejected(t *testing.T) {
|
||||||
@@ -116,3 +127,155 @@ func TestValidCallNumber(t *testing.T) {
|
|||||||
t.Fatal("call number validation mismatch")
|
t.Fatal("call number validation mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
||||||
|
client, peer := net.Pipe()
|
||||||
|
defer peer.Close()
|
||||||
|
refreshContext, cancelRefresh := context.WithCancel(context.Background())
|
||||||
|
defer cancelRefresh()
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{
|
||||||
|
UserAgent: "VoCat Test",
|
||||||
|
SecurityMode: SecurityDisabled,
|
||||||
|
}},
|
||||||
|
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
|
||||||
|
HomeMCC: "234", HomeMNC: "33", IMSI: "234330000000001", ICCID: "8944300000000000000",
|
||||||
|
}},
|
||||||
|
identity: identitySet{
|
||||||
|
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||||
|
public: "sip:[email protected]",
|
||||||
|
user: "234330000000001",
|
||||||
|
},
|
||||||
|
endpoint: pcscfEndpoint{host: "pcscf.test", port: 5060},
|
||||||
|
transport: "tcp",
|
||||||
|
conn: client,
|
||||||
|
fromTag: "local-tag",
|
||||||
|
instanceID: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||||
|
cseq: 1,
|
||||||
|
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||||
|
calls: make(map[string]*imsCall),
|
||||||
|
refreshContext: refreshContext,
|
||||||
|
evidence: vowifi.IMSEvidence{
|
||||||
|
PAssociatedURI: []string{"<tel:+447700900123>"},
|
||||||
|
ServiceRoute: []string{"<sip:route.ims.test;lr>"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wireResult := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
packet, _ := io.ReadAll(peer)
|
||||||
|
wireResult <- string(packet)
|
||||||
|
}()
|
||||||
|
|
||||||
|
call, err := session.DialCall(context.Background(), "888")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session.finishCall(call.ID, "ended", 0, "test complete")
|
||||||
|
cancelRefresh()
|
||||||
|
_ = client.Close()
|
||||||
|
wire := <-wireResult
|
||||||
|
|
||||||
|
for _, expected := range []string{
|
||||||
|
"INVITE sip:[email protected] SIP/2.0\r\n",
|
||||||
|
"To: <sip:[email protected]>\r\n",
|
||||||
|
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
||||||
|
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||||
|
"P-Preferred-Service: " + mmtelServiceURN + "\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",
|
||||||
|
"User-Agent: VoCat Test\r\n",
|
||||||
|
"Accept: application/sdp\r\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(wire, expected) {
|
||||||
|
t.Fatalf("INVITE omitted %q:\n%s", expected, wire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
|
||||||
|
session := &Session{
|
||||||
|
identity: identitySet{
|
||||||
|
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||||
|
public: "sip:[email protected]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
from, preferred, source := session.callOriginatingIdentitiesLocked(vowifi.CarrierProfile{})
|
||||||
|
if from != session.identity.public || preferred != session.identity.public || source != "registered_impu" {
|
||||||
|
t.Fatalf("fallback identities = (%q, %q, %q)", from, preferred, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallTargetURIUsesPhoneContextOnlyForLocalNumbers(t *testing.T) {
|
||||||
|
domain := "ims.mnc033.mcc234.3gppnetwork.org"
|
||||||
|
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:888;phone-context="+domain {
|
||||||
|
t.Fatalf("local target = %q", got)
|
||||||
|
}
|
||||||
|
if got := callTargetURI("+447700900123", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:+447700900123" {
|
||||||
|
t.Fatalf("global target = %q", got)
|
||||||
|
}
|
||||||
|
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip"}); got != "sip:888@"+domain {
|
||||||
|
t.Fatalf("SIP target = %q", got)
|
||||||
|
}
|
||||||
|
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip", IMSUserEqPhone: true}); got != "sip:888@"+domain+";user=phone" {
|
||||||
|
t.Fatalf("SIP user=phone target = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallResponseDiagnosticIncludesNetworkReason(t *testing.T) {
|
||||||
|
response := &sipResponse{
|
||||||
|
StatusCode: 487,
|
||||||
|
Reason: "Request Terminated",
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"reason": {`Q.850;cause=31;text="Normal, unspecified"`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
want := `Request Terminated; Reason: Q.850;cause=31;text="Normal, unspecified"`
|
||||||
|
if got := callResponseDiagnostic(response); got != want {
|
||||||
|
t.Fatalf("diagnostic = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedInviteACKUsesOriginalTransaction(t *testing.T) {
|
||||||
|
client, peer := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer peer.Close()
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{UserAgent: "VoCat Test"}},
|
||||||
|
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "33"}},
|
||||||
|
identity: identitySet{domain: "ims.mnc033.mcc234.3gppnetwork.org"},
|
||||||
|
transport: "tcp",
|
||||||
|
conn: client,
|
||||||
|
}
|
||||||
|
call := &imsCall{
|
||||||
|
callID: "call-1",
|
||||||
|
inviteTarget: "sip:[email protected]",
|
||||||
|
from: "<sip:[email protected]>;tag=local",
|
||||||
|
to: "<sip:[email protected]>",
|
||||||
|
branch: "original-branch",
|
||||||
|
cseq: 41,
|
||||||
|
routes: []string{"<sip:pcscf.test;lr>"},
|
||||||
|
}
|
||||||
|
response := &sipResponse{StatusCode: 487, Headers: map[string][]string{
|
||||||
|
"to": {"<sip:[email protected]>;tag=tas"},
|
||||||
|
}}
|
||||||
|
ackResult := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
buffer := make([]byte, 4096)
|
||||||
|
count, _ := peer.Read(buffer)
|
||||||
|
ackResult <- string(buffer[:count])
|
||||||
|
}()
|
||||||
|
if err := session.sendRejectedInviteACK(call, response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ack := <-ackResult
|
||||||
|
for _, expected := range []string{
|
||||||
|
"ACK sip:[email protected] SIP/2.0\r\n",
|
||||||
|
"branch=z9hG4bKoriginal-branch;rport",
|
||||||
|
"To: <sip:[email protected]>;tag=tas\r\n",
|
||||||
|
"CSeq: 41 ACK\r\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(ack, expected) {
|
||||||
|
t.Fatalf("rejected INVITE ACK omitted %q:\n%s", expected, ack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+102
-54
@@ -66,6 +66,10 @@ 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
|
||||||
// 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
|
||||||
@@ -704,10 +708,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 +911,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 +925,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"
|
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,22 +955,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"`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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(),
|
||||||
@@ -1014,6 +1020,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
|
||||||
@@ -1366,13 +1399,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()
|
||||||
|
|||||||
@@ -375,7 +375,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
return fmt.Errorf("unexpected start line %q", startLine)
|
return fmt.Errorf("unexpected start line %q", startLine)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{
|
for _, forbidden := range []string{
|
||||||
"p-access-network-info",
|
|
||||||
"p-visited-network-id",
|
"p-visited-network-id",
|
||||||
"p-preferred-identity",
|
"p-preferred-identity",
|
||||||
} {
|
} {
|
||||||
@@ -387,6 +386,13 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
|
||||||
|
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||||
|
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||||
|
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
||||||
|
}
|
||||||
if step == 0 {
|
if step == 0 {
|
||||||
if headers["authorization"] != "" {
|
if headers["authorization"] != "" {
|
||||||
return errors.New("initial REGISTER unexpectedly authenticated")
|
return errors.New("initial REGISTER unexpectedly authenticated")
|
||||||
|
|||||||
@@ -534,25 +534,7 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
|||||||
for _, protocol := range flow.protocols {
|
for _, protocol := range flow.protocols {
|
||||||
operations = append(operations, xfrmOperation{
|
operations = append(operations, xfrmOperation{
|
||||||
description: flow.description + " " + protocol + " policy",
|
description: flow.description + " " + protocol + " policy",
|
||||||
arguments: []string{
|
arguments: xfrmPolicyArgs(flow, protocol, false),
|
||||||
flow.family,
|
|
||||||
"xfrm", "policy", "add",
|
|
||||||
"src", flow.sourcePrefix,
|
|
||||||
"dst", flow.destinationPrefix,
|
|
||||||
"proto", protocol,
|
|
||||||
"sport", strconv.Itoa(flow.sourcePort),
|
|
||||||
"dport", strconv.Itoa(flow.destinationPort),
|
|
||||||
"dir", flow.direction,
|
|
||||||
"priority", "100",
|
|
||||||
"tmpl",
|
|
||||||
"src", flow.templateSource.String(),
|
|
||||||
"dst", flow.templateDestination.String(),
|
|
||||||
"proto", "esp",
|
|
||||||
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
|
||||||
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
|
||||||
"mode", "transport",
|
|
||||||
"level", "required",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,16 +550,7 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
|||||||
protocol := flow.protocols[protocolIndex]
|
protocol := flow.protocols[protocolIndex]
|
||||||
operations = append(operations, xfrmOperation{
|
operations = append(operations, xfrmOperation{
|
||||||
description: "delete " + flow.description + " " + protocol + " policy",
|
description: "delete " + flow.description + " " + protocol + " policy",
|
||||||
arguments: []string{
|
arguments: xfrmPolicyArgs(flow, protocol, true),
|
||||||
flow.family,
|
|
||||||
"xfrm", "policy", "delete",
|
|
||||||
"src", flow.sourcePrefix,
|
|
||||||
"dst", flow.destinationPrefix,
|
|
||||||
"proto", protocol,
|
|
||||||
"sport", strconv.Itoa(flow.sourcePort),
|
|
||||||
"dport", strconv.Itoa(flow.destinationPort),
|
|
||||||
"dir", flow.direction,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,6 +579,44 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
|||||||
return operations
|
return operations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func xfrmPolicyArgs(flow xfrmFlow, protocol string, delete bool) []string {
|
||||||
|
args := []string{
|
||||||
|
flow.family,
|
||||||
|
"xfrm", "policy",
|
||||||
|
}
|
||||||
|
if delete {
|
||||||
|
args = append(args, "delete")
|
||||||
|
} else {
|
||||||
|
args = append(args, "add")
|
||||||
|
}
|
||||||
|
args = append(args,
|
||||||
|
"src", flow.sourcePrefix,
|
||||||
|
"dst", flow.destinationPrefix,
|
||||||
|
"proto", protocol,
|
||||||
|
)
|
||||||
|
if flow.sourcePort > 0 {
|
||||||
|
args = append(args, "sport", strconv.Itoa(flow.sourcePort))
|
||||||
|
}
|
||||||
|
if flow.destinationPort > 0 {
|
||||||
|
args = append(args, "dport", strconv.Itoa(flow.destinationPort))
|
||||||
|
}
|
||||||
|
args = append(args, "dir", flow.direction)
|
||||||
|
if delete {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
return append(args,
|
||||||
|
"priority", "100",
|
||||||
|
"tmpl",
|
||||||
|
"src", flow.templateSource.String(),
|
||||||
|
"dst", flow.templateDestination.String(),
|
||||||
|
"proto", "esp",
|
||||||
|
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
||||||
|
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
||||||
|
"mode", "transport",
|
||||||
|
"level", "required",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type xfrmFlow struct {
|
type xfrmFlow struct {
|
||||||
description string
|
description string
|
||||||
family string
|
family string
|
||||||
@@ -650,7 +661,7 @@ func xfrmFlows(config IPSecSAConfig) []xfrmFlow {
|
|||||||
{
|
{
|
||||||
description: "P-CSCF-client to UE-server", family: family,
|
description: "P-CSCF-client to UE-server", family: family,
|
||||||
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
||||||
sourcePort: config.PCSCFClientPort, destinationPort: config.UEServerPort,
|
sourcePort: 0, destinationPort: config.UEServerPort,
|
||||||
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
||||||
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
||||||
protocols: []string{"tcp", "udp"},
|
protocols: []string{"tcp", "udp"},
|
||||||
@@ -772,7 +783,7 @@ func zeroBytes(value []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) securityOffered() bool {
|
func (session *Session) securityOffered() bool {
|
||||||
return session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
return session.provider != nil && session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) {
|
func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) {
|
||||||
@@ -916,9 +927,7 @@ func (session *Session) validProtectedUDPSource(remote *net.UDPAddr) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expectedIP := addressIP(session.conn.RemoteAddr())
|
expectedIP := addressIP(session.conn.RemoteAddr())
|
||||||
return expectedIP != nil &&
|
return expectedIP != nil && expectedIP.Equal(remote.IP)
|
||||||
expectedIP.Equal(remote.IP) &&
|
|
||||||
remote.Port == session.securityAgreement.selected.portClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) effectiveSecurityMode() string {
|
func (session *Session) effectiveSecurityMode() string {
|
||||||
|
|||||||
@@ -531,8 +531,8 @@ func serveProtectedRegistrar(
|
|||||||
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
||||||
strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
!strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
||||||
return result, errors.New("registration transaction advertised or sent MESSAGE")
|
return result, errors.New("registration transaction did not advertise MESSAGE correctly")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := protectedConnection.Write(testResponse(
|
if _, err := protectedConnection.Write(testResponse(
|
||||||
|
|||||||
@@ -287,14 +287,18 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
|||||||
"tcp 40666 50600 out": false,
|
"tcp 40666 50600 out": false,
|
||||||
"udp 40666 50600 out": false,
|
"udp 40666 50600 out": false,
|
||||||
"tcp 50600 40666 in": false,
|
"tcp 50600 40666 in": false,
|
||||||
"tcp 50601 55610 in": false,
|
"tcp * 55610 in": false,
|
||||||
"udp 50601 55610 in": false,
|
"udp * 55610 in": false,
|
||||||
"tcp 55610 50601 out": false,
|
"tcp 55610 50601 out": false,
|
||||||
}
|
}
|
||||||
for _, operation := range install[4:] {
|
for _, operation := range install[4:] {
|
||||||
|
sourcePort := "*"
|
||||||
|
if value, ok := optionalArgumentAfter(operation.arguments, "sport"); ok {
|
||||||
|
sourcePort = value
|
||||||
|
}
|
||||||
key := strings.Join([]string{
|
key := strings.Join([]string{
|
||||||
argumentAfter(t, operation.arguments, "proto"),
|
argumentAfter(t, operation.arguments, "proto"),
|
||||||
argumentAfter(t, operation.arguments, "sport"),
|
sourcePort,
|
||||||
argumentAfter(t, operation.arguments, "dport"),
|
argumentAfter(t, operation.arguments, "dport"),
|
||||||
argumentAfter(t, operation.arguments, "dir"),
|
argumentAfter(t, operation.arguments, "dir"),
|
||||||
}, " ")
|
}, " ")
|
||||||
@@ -397,6 +401,15 @@ func argumentAfter(t *testing.T, arguments []string, name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalArgumentAfter(arguments []string, name string) (string, bool) {
|
||||||
|
for index := 0; index+1 < len(arguments); index++ {
|
||||||
|
if arguments[index] == name {
|
||||||
|
return arguments[index+1], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func containsArguments(arguments []string, sequence ...string) bool {
|
func containsArguments(arguments []string, sequence ...string) bool {
|
||||||
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -91,11 +112,14 @@ func (session *Session) startRuntimeReceivers() error {
|
|||||||
|
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.readMainConnection()
|
go session.readMainConnection()
|
||||||
if session.securityActive && session.transport == "tcp" && session.protectedTCP != nil {
|
// Vodafone UK (and others) deliver MT SMS as SIP MESSAGE to the
|
||||||
|
// ipsec-3gpp UE server port over UDP even when REGISTER used TCP.
|
||||||
|
// Always read both sockets when they were reserved.
|
||||||
|
if session.securityActive && session.protectedTCP != nil {
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.acceptProtectedTCP()
|
go session.acceptProtectedTCP()
|
||||||
}
|
}
|
||||||
if session.securityActive && session.transport == "udp" && session.protectedUDP != nil {
|
if session.securityActive && session.protectedUDP != nil {
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.readProtectedUDP()
|
go session.readProtectedUDP()
|
||||||
}
|
}
|
||||||
@@ -140,6 +164,8 @@ func (session *Session) acceptProtectedTCP() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound TCP rejected", nil,
|
||||||
|
"stage", "source_filter", "remote", connection.RemoteAddr().String())
|
||||||
_ = connection.Close()
|
_ = connection.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -186,6 +212,8 @@ func (session *Session) readProtectedUDP() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !session.validProtectedUDPSource(remote) {
|
if !session.validProtectedUDPSource(remote) {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound UDP rejected", nil,
|
||||||
|
"stage", "source_filter", "remote", remote.String(), "packet_bytes", count)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
packet, err := parseSIPPacket(buffer[:count])
|
packet, err := parseSIPPacket(buffer[:count])
|
||||||
@@ -208,8 +236,9 @@ func (session *Session) validProtectedTCPSource(address net.Addr) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expected := addressIP(session.conn.RemoteAddr())
|
expected := addressIP(session.conn.RemoteAddr())
|
||||||
return expected != nil && expected.Equal(remote.IP) &&
|
// Require P-CSCF IP. Do not require port-c (50601): some cores originate
|
||||||
remote.Port == session.securityAgreement.selected.portClient
|
// MESSAGE from an ephemeral port on the same P-CSCF.
|
||||||
|
return expected != nil && expected.Equal(remote.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) {
|
func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) {
|
||||||
@@ -335,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:
|
||||||
@@ -354,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)
|
||||||
@@ -376,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 == "" {
|
||||||
@@ -406,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
|
||||||
@@ -438,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,
|
||||||
@@ -469,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 status.MessageReference == 0 && status.StatusCode == 0 && 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)
|
||||||
@@ -483,16 +534,10 @@ 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 {
|
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
|
|
||||||
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
|
|
||||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var serviceCenterTimestamp *time.Time
|
var serviceCenterTimestamp *time.Time
|
||||||
if message.ServiceCenterTimestamp != nil {
|
if message.ServiceCenterTimestamp != nil {
|
||||||
value := message.ServiceCenterTimestamp.UTC()
|
value := message.ServiceCenterTimestamp.UTC()
|
||||||
@@ -515,12 +560,9 @@ 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.OnSMS != nil {
|
err := session.invokeSMSCallback(received)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
err = session.provider.config.OnSMS(ctx, received)
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
|
||||||
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
|
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||||
@@ -530,8 +572,46 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
|||||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
|
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
|
||||||
"stage", "sms_callback", "payload_source", payloadSource,
|
"stage", "sms_callback", "payload_source", payloadSource,
|
||||||
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
|
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
|
||||||
"concatenated", message.Concat != nil)
|
"concatenated", message.Concat != nil, "decode_error", decodeErr != nil)
|
||||||
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
||||||
|
|
||||||
|
default:
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
|
||||||
|
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
|
||||||
|
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) invokeSMSCallback(received ReceivedSMS) error {
|
||||||
|
if session.provider.config.OnSMS == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return session.provider.config.OnSMS(ctx, received)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) invokeSMSStatusCallback(status ReceivedSMSStatus) error {
|
||||||
|
if session.provider.config.OnSMSStatus == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return session.provider.config.OnSMSStatus(ctx, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtrValue(value *int) int {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSMSPayload(request *sipRequest) ([]byte, string, error) {
|
func extractSMSPayload(request *sipRequest) ([]byte, string, error) {
|
||||||
@@ -599,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 {
|
||||||
@@ -656,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
|
||||||
}
|
}
|
||||||
@@ -802,6 +1111,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 {
|
||||||
@@ -841,7 +1164,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",
|
||||||
)
|
)
|
||||||
@@ -849,7 +1176,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)),
|
||||||
"", "",
|
"", "",
|
||||||
@@ -1017,3 +1344,4 @@ func (session *Session) closeInboundConnections() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ vowifi.SMSSender = (*Session)(nil)
|
var _ vowifi.SMSSender = (*Session)(nil)
|
||||||
|
var _ vowifi.USSISender = (*Session)(nil)
|
||||||
|
|||||||
@@ -158,6 +158,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",
|
||||||
@@ -539,3 +590,271 @@ 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 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -405,6 +434,7 @@ type Call struct {
|
|||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
AnsweredAt *time.Time `json:"answered_at,omitempty"`
|
||||||
SIPCode int `json:"sip_code,omitempty"`
|
SIPCode int `json:"sip_code,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
MediaReady bool `json:"media_ready,omitempty"`
|
MediaReady bool `json:"media_ready,omitempty"`
|
||||||
|
|||||||
+38
-4
@@ -3,8 +3,8 @@
|
|||||||
# 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:
|
||||||
@@ -195,6 +195,39 @@ 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
|
||||||
|
if opkg_has_package libqmi; then
|
||||||
|
opkg install libqmi >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
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
|
||||||
@@ -263,8 +296,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 ---------------------------
|
||||||
@@ -538,6 +571,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
|
||||||
|
|||||||
@@ -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,6 +23,8 @@ 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
|
||||||
|
|||||||
@@ -40,23 +40,35 @@ function ProbeResultPanel({ probe }: { probe: UpstreamProbeResult }) {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const reachable = !!probe.reachable;
|
const reachable = !!probe.reachable;
|
||||||
const handshakeOk = !!probe.handshakeOk;
|
const handshakeOk = !!probe.handshakeOk;
|
||||||
const udpOk = !!probe.udpAssociateOk;
|
const associateOk = !!probe.udpAssociateOk;
|
||||||
|
const udpOk = !!probe.udpExchangeOk;
|
||||||
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
||||||
const udpState: ProbeState = !handshakeOk ? "pending" : udpOk ? "ok" : "fail";
|
const associateState: ProbeState = !handshakeOk ? "pending" : associateOk ? "ok" : "fail";
|
||||||
|
const udpState: ProbeState = !associateOk ? "pending" : udpOk ? "ok" : "fail";
|
||||||
return (
|
return (
|
||||||
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
||||||
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
||||||
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
||||||
<ProbeRow
|
<ProbeRow
|
||||||
state={udpState}
|
state={associateState}
|
||||||
label={t("UDP Associate(VoWiFi 依赖)")}
|
label={t("UDP Associate(VoWiFi 依赖)")}
|
||||||
detail={udpState === "pending" ? undefined : udpOk ? t("支持") : t("不支持")}
|
detail={associateState === "pending" ? undefined : associateOk ? t("已建立") : t("不支持")}
|
||||||
|
/>
|
||||||
|
<ProbeRow
|
||||||
|
state={udpState}
|
||||||
|
label={t("真实 UDP DNS 往返")}
|
||||||
|
detail={udpState === "pending" ? undefined : udpOk ? `${probe.roundTripMs || 0} ms` : t("无返回")}
|
||||||
/>
|
/>
|
||||||
{probe.relayAddr ? (
|
{probe.relayAddr ? (
|
||||||
<div className="text-[11px] text-gray-400">
|
<div className="text-[11px] text-gray-400">
|
||||||
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{probe.dnsName && probe.dnsServer ? (
|
||||||
|
<div className="text-[11px] text-gray-400">
|
||||||
|
{t("UDP 测试:")}<span className="font-mono">{probe.dnsName} @ {probe.dnsServer}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
||||||
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +139,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
|
|||||||
</div>
|
</div>
|
||||||
{probe ? (
|
{probe ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SectionHeader tone={probe.udpAssociateOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
<SectionHeader tone={probe.udpExchangeOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||||
<ProbeResultPanel probe={probe} />
|
<ProbeResultPanel probe={probe} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ export interface UpstreamProbeResult {
|
|||||||
reachable?: boolean;
|
reachable?: boolean;
|
||||||
handshakeOk?: boolean;
|
handshakeOk?: boolean;
|
||||||
udpAssociateOk?: boolean;
|
udpAssociateOk?: boolean;
|
||||||
|
udpExchangeOk?: boolean;
|
||||||
authMethod?: string;
|
authMethod?: string;
|
||||||
relayAddr?: string;
|
relayAddr?: string;
|
||||||
|
dnsServer?: string;
|
||||||
|
dnsName?: string;
|
||||||
|
dnsRcode?: number;
|
||||||
|
roundTripMs?: number;
|
||||||
diagnosis?: string;
|
diagnosis?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function PushplusTab({ value, onChange }: ChannelProps<PushplusForm>) {
|
|||||||
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
|
||||||
</div>
|
</div>
|
||||||
<Field label="Token">
|
<Field label="Token">
|
||||||
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
|
|||||||
import { Switch } from "../ui/Switch";
|
import { Switch } from "../ui/Switch";
|
||||||
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
|
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
|
||||||
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
|
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
|
||||||
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
|
import type { BarkForm, EmailForm, HeaderRow, LarkForm, WebhookForm, WecomForm } from "./model";
|
||||||
|
|
||||||
const HEADER_LIST_ID = "vocat-webhook-header-names";
|
const HEADER_LIST_ID = "vocat-webhook-header-names";
|
||||||
|
|
||||||
@@ -21,11 +21,11 @@ function hasAnyUrl(urls: string[]): boolean {
|
|||||||
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
|
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SMSOnlyHint() {
|
function OneWayNotificationHint() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ export function BarkTab({ value, onChange, testing, onTest }: PushChannelProps<B
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<UrlListEditor
|
<UrlListEditor
|
||||||
urls={value.urls}
|
urls={value.urls}
|
||||||
@@ -97,7 +97,7 @@ export function EmailTab({ value, onChange, testing, onTest }: PushChannelProps<
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
|
||||||
<Field label={t("SMTP 主机")} className="sm:col-span-5">
|
<Field label={t("SMTP 主机")} className="sm:col-span-5">
|
||||||
@@ -163,7 +163,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<UrlListEditor
|
<UrlListEditor
|
||||||
urls={value.urls}
|
urls={value.urls}
|
||||||
@@ -284,7 +284,7 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
|
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
|
||||||
@@ -317,3 +317,77 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LarkTab({ value, onChange, testing, onTest }: PushChannelProps<LarkForm>) {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const off = !value.enabled;
|
||||||
|
const complete = !!value.url.trim() && !!value.payloadTemplate.trim() && (!value.signingEnabled || !!value.secret.trim());
|
||||||
|
return (
|
||||||
|
<div className="pt-2">
|
||||||
|
<ChannelHeader
|
||||||
|
title={t("启用飞书 / Lark 群自定义机器人通知")}
|
||||||
|
enabled={value.enabled}
|
||||||
|
onToggle={(enabled) => onChange({ enabled })}
|
||||||
|
actions={
|
||||||
|
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
|
||||||
|
{t("测试通知")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<OneWayNotificationHint />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
|
{t("支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。")}
|
||||||
|
</div>
|
||||||
|
<Field label={t("群机器人 Webhook URL")}>
|
||||||
|
<Input
|
||||||
|
value={value.url}
|
||||||
|
onChange={(event) => {
|
||||||
|
const url = event.target.value;
|
||||||
|
onChange(value.url === "********" && url !== value.url ? { url, secret: "" } : { url });
|
||||||
|
}}
|
||||||
|
disabled={off}
|
||||||
|
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-bold uppercase tracking-wider text-gray-500">{t("启用签名校验")}</label>
|
||||||
|
<div className="flex h-10 items-center">
|
||||||
|
<Switch checked={value.signingEnabled} onChange={(signingEnabled) => onChange({ signingEnabled })} disabled={off} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{value.signingEnabled ? (
|
||||||
|
<Field
|
||||||
|
label={t("签名密钥 (Secret)")}
|
||||||
|
hint={t("填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={value.secret}
|
||||||
|
onChange={(event) => onChange({ secret: event.target.value })}
|
||||||
|
disabled={off}
|
||||||
|
type="password"
|
||||||
|
placeholder={t("群机器人签名密钥")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field
|
||||||
|
label={t("JSON 请求体模板")}
|
||||||
|
hint={
|
||||||
|
<>
|
||||||
|
{t("支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
|
||||||
|
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={value.payloadTemplate}
|
||||||
|
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
|
||||||
|
disabled={off}
|
||||||
|
rows={12}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export interface WecomForm {
|
|||||||
payloadTemplate: string;
|
payloadTemplate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LarkForm {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
signingEnabled: boolean;
|
||||||
|
secret: string;
|
||||||
|
payloadTemplate: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||||
"msgtype": "text",
|
"msgtype": "text",
|
||||||
"text": {
|
"text": {
|
||||||
@@ -66,6 +74,13 @@ export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
export const DEFAULT_LARK_PAYLOAD_TEMPLATE = `{
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": {
|
||||||
|
"text": {{message}}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
export interface NotifyForms {
|
export interface NotifyForms {
|
||||||
telegram: TelegramForm;
|
telegram: TelegramForm;
|
||||||
webhook: WebhookForm;
|
webhook: WebhookForm;
|
||||||
@@ -73,6 +88,7 @@ export interface NotifyForms {
|
|||||||
email: EmailForm;
|
email: EmailForm;
|
||||||
pushplus: PushplusForm;
|
pushplus: PushplusForm;
|
||||||
wecom: WecomForm;
|
wecom: WecomForm;
|
||||||
|
lark: LarkForm;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
|
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
|
||||||
@@ -148,6 +164,7 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
|
|||||||
const email = asRecord(data.email);
|
const email = asRecord(data.email);
|
||||||
const pushplus = asRecord(data.pushplus);
|
const pushplus = asRecord(data.pushplus);
|
||||||
const wecom = asRecord(data.wecom);
|
const wecom = asRecord(data.wecom);
|
||||||
|
const lark = asRecord(data.lark);
|
||||||
return {
|
return {
|
||||||
telegram: {
|
telegram: {
|
||||||
enabled: !!telegram.enabled,
|
enabled: !!telegram.enabled,
|
||||||
@@ -197,6 +214,13 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
|
|||||||
urls: strList(wecom.urls),
|
urls: strList(wecom.urls),
|
||||||
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
|
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
|
||||||
},
|
},
|
||||||
|
lark: {
|
||||||
|
enabled: !!lark.enabled,
|
||||||
|
url: str(lark.url),
|
||||||
|
signingEnabled: !!lark.signingEnabled,
|
||||||
|
secret: lark.signingEnabled ? str(lark.secret) : "",
|
||||||
|
payloadTemplate: str(lark.payloadTemplate ?? lark.payload_template) || DEFAULT_LARK_PAYLOAD_TEMPLATE,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +279,24 @@ export function buildWecomPayload(form: WecomForm, forTest = false) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildLarkPayload(form: LarkForm, forTest = false) {
|
||||||
|
const payload: {
|
||||||
|
enabled: boolean;
|
||||||
|
url?: string;
|
||||||
|
signing_enabled: boolean;
|
||||||
|
payload_template: string;
|
||||||
|
secret?: string;
|
||||||
|
} = {
|
||||||
|
enabled: !!form.enabled,
|
||||||
|
signing_enabled: !!form.signingEnabled,
|
||||||
|
payload_template: String(form.payloadTemplate || ""),
|
||||||
|
};
|
||||||
|
const url = forTest ? String(form.url || "").trim() : String(form.url || "");
|
||||||
|
if (url) payload.url = url;
|
||||||
|
if (form.signingEnabled) payload.secret = String(form.secret || "");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildNotificationsPayload(forms: NotifyForms) {
|
export function buildNotificationsPayload(forms: NotifyForms) {
|
||||||
return {
|
return {
|
||||||
telegram: {
|
telegram: {
|
||||||
@@ -276,5 +318,6 @@ export function buildNotificationsPayload(forms: NotifyForms) {
|
|||||||
webhook: buildWebhookPayload(forms.webhook),
|
webhook: buildWebhookPayload(forms.webhook),
|
||||||
bark: buildBarkPayload(forms.bark),
|
bark: buildBarkPayload(forms.bark),
|
||||||
wecom: buildWecomPayload(forms.wecom),
|
wecom: buildWecomPayload(forms.wecom),
|
||||||
|
lark: buildLarkPayload(forms.lark),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-3
@@ -325,6 +325,7 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"Bark 测试失败": "Bark test failed",
|
"Bark 测试失败": "Bark test failed",
|
||||||
"Email 测试失败": "Email test failed",
|
"Email 测试失败": "Email test failed",
|
||||||
"企业微信消息推送测试失败": "WeCom message push test failed",
|
"企业微信消息推送测试失败": "WeCom message push test failed",
|
||||||
|
"飞书 / Lark 群机器人通知测试失败": "Feishu / Lark group bot notification test failed",
|
||||||
|
|
||||||
// ---- 设置页:安全卡 ----
|
// ---- 设置页:安全卡 ----
|
||||||
安全: "Security",
|
安全: "Security",
|
||||||
@@ -394,8 +395,8 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"启用后会推送新短信,并允许指定管理员通过 Bot 查看状态、切卡、管理 WiFi Calling、发送短信和限时拨号。拨号只执行呼叫并自动挂断,不处理音频。":
|
"启用后会推送新短信,并允许指定管理员通过 Bot 查看状态、切卡、管理 WiFi Calling、发送短信和限时拨号。拨号只执行呼叫并自动挂断,不处理音频。":
|
||||||
"When enabled, new SMS messages are pushed and the designated administrator can check status, switch profiles, manage WiFi Calling, send SMS, and place timed calls. Calls only dial and hang up automatically; audio is not processed.",
|
"When enabled, new SMS messages are pushed and the designated administrator can check status, switch profiles, manage WiFi Calling, send SMS, and place timed calls. Calls only dial and hang up automatically; audio is not processed.",
|
||||||
"启用 Pushplus 推送": "Enable Pushplus",
|
"启用 Pushplus 推送": "Enable Pushplus",
|
||||||
"该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。":
|
"该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。":
|
||||||
"This channel only pushes newly received SMS messages and provides no device controls. Every SMS is pushed separately and is not merged by content.",
|
"This channel is for one-way notifications only and provides no device controls. New SMS messages are delivered individually, and notification-enabled automatic tasks also send their results.",
|
||||||
"例如 123456": "e.g. 123456",
|
"例如 123456": "e.g. 123456",
|
||||||
"接收短信通知和命令回复的私聊或群组 ID。群组 ID 可以是负数。":
|
"接收短信通知和命令回复的私聊或群组 ID。群组 ID 可以是负数。":
|
||||||
"Private chat or group ID that receives SMS notifications and command replies. Group IDs may be negative.",
|
"Private chat or group ID that receives SMS notifications and command replies. Group IDs may be negative.",
|
||||||
@@ -424,7 +425,9 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"启用 Webhook 推送": "Enable Webhook",
|
"启用 Webhook 推送": "Enable Webhook",
|
||||||
"企业微信消息推送": "WeCom Message Push",
|
"企业微信消息推送": "WeCom Message Push",
|
||||||
"启用企业微信消息推送": "Enable WeCom Message Push",
|
"启用企业微信消息推送": "Enable WeCom Message Push",
|
||||||
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
|
"飞书 / Lark 群机器人": "Feishu / Lark Group Bot",
|
||||||
|
"启用飞书 / Lark 群自定义机器人通知": "Enable Feishu / Lark Custom Group Bot Notifications",
|
||||||
|
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信 / 飞书 / Lark 群机器人": "Telegram / Bark / Email / Pushplus / Webhook / WeCom / Feishu / Lark Group Bot",
|
||||||
"目标 URLs": "Target URLs",
|
"目标 URLs": "Target URLs",
|
||||||
"添加 URL": "Add URL",
|
"添加 URL": "Add URL",
|
||||||
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
|
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
|
||||||
@@ -436,6 +439,16 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
|
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
|
||||||
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
|
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
|
||||||
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
|
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
|
||||||
|
"支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。":
|
||||||
|
"Supports custom group bot Webhooks for Feishu at open.feishu.cn and international Lark at open.larksuite.com; no app is required.",
|
||||||
|
"群机器人 Webhook URL": "Group Bot Webhook URL",
|
||||||
|
启用签名校验: "Enable Signature Verification",
|
||||||
|
"签名密钥 (Secret)": "Signing Secret",
|
||||||
|
"填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。":
|
||||||
|
"Enter the signing secret generated in the group bot security settings. The Webhook URL and secret are treated as sensitive and masked in the UI.",
|
||||||
|
群机器人签名密钥: "Group bot signing secret",
|
||||||
|
"支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如":
|
||||||
|
"Supports a complete Feishu / Lark custom group bot JSON payload. Use variables as JSON values, for example",
|
||||||
"分组 (Group)": "Group",
|
"分组 (Group)": "Group",
|
||||||
"例如 vocat": "e.g. vocat",
|
"例如 vocat": "e.g. vocat",
|
||||||
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
|
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
|
||||||
@@ -672,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",
|
||||||
@@ -684,6 +699,11 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"SIM / 设备": "SIM / Device",
|
"SIM / 设备": "SIM / Device",
|
||||||
"SM-DP+ 地址 *": "SM-DP+ Address *",
|
"SM-DP+ 地址 *": "SM-DP+ Address *",
|
||||||
"SOCKS5 认证与 UDP ASSOCIATE 探测通过": "SOCKS5 auth and UDP ASSOCIATE probes passed",
|
"SOCKS5 认证与 UDP ASSOCIATE 探测通过": "SOCKS5 auth and UDP ASSOCIATE probes passed",
|
||||||
|
"SOCKS5 认证与真实 UDP 往返探测通过": "SOCKS5 authentication and real UDP round-trip probe passed",
|
||||||
|
"真实 UDP DNS 往返": "Real UDP DNS round trip",
|
||||||
|
"已建立": "Established",
|
||||||
|
"无返回": "No response",
|
||||||
|
"UDP 测试:": "UDP test: ",
|
||||||
"Telegram / Bark / Email / Pushplus / Webhook": "Telegram / Bark / Email / Pushplus / Webhook",
|
"Telegram / Bark / Email / Pushplus / Webhook": "Telegram / Bark / Email / Pushplus / Webhook",
|
||||||
"USB 路径": "USB Path",
|
"USB 路径": "USB Path",
|
||||||
"USSD 交互终端": "USSD Interactive Terminal",
|
"USSD 交互终端": "USSD Interactive Terminal",
|
||||||
@@ -802,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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ export default function ProxyPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
setUpstreamProbe(data.probe || null);
|
setUpstreamProbe(data.probe || null);
|
||||||
if (data.probe?.udpAssociateOk) {
|
if (data.probe?.udpExchangeOk) {
|
||||||
message.success(data.message || t("SOCKS5 鉴权和 UDP Associate 探测通过"));
|
message.success(data.message || t("SOCKS5 认证与真实 UDP 往返探测通过"));
|
||||||
} else {
|
} else {
|
||||||
message.warning(data.message || t("代理不能承载 VoWiFi 所需的 UDP"));
|
message.warning(data.message || t("代理不能承载 VoWiFi 所需的 UDP"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useAuth } from "../store/auth";
|
|||||||
import {
|
import {
|
||||||
buildBarkPayload,
|
buildBarkPayload,
|
||||||
buildEmailPayload,
|
buildEmailPayload,
|
||||||
|
buildLarkPayload,
|
||||||
buildNotificationsPayload,
|
buildNotificationsPayload,
|
||||||
buildWecomPayload,
|
buildWecomPayload,
|
||||||
buildWebhookPayload,
|
buildWebhookPayload,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
type NotifyForms,
|
type NotifyForms,
|
||||||
} from "../components/settings/model";
|
} from "../components/settings/model";
|
||||||
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
|
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
|
||||||
import { BarkTab, EmailTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
|
import { BarkTab, EmailTab, LarkTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
|
||||||
import { PluginsCard } from "../components/settings/PluginsCard";
|
import { PluginsCard } from "../components/settings/PluginsCard";
|
||||||
import { HTTPSCard } from "../components/settings/HTTPSCard";
|
import { HTTPSCard } from "../components/settings/HTTPSCard";
|
||||||
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
|
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
|
||||||
@@ -36,6 +37,7 @@ const NOTIFY_TABS = [
|
|||||||
{ key: "pushplus", label: "Pushplus" },
|
{ key: "pushplus", label: "Pushplus" },
|
||||||
{ key: "webhook", label: "Webhook" },
|
{ key: "webhook", label: "Webhook" },
|
||||||
{ key: "wecom", label: "企业微信消息推送" },
|
{ key: "wecom", label: "企业微信消息推送" },
|
||||||
|
{ key: "lark", label: "飞书 / Lark 群机器人" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
|
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
|
||||||
@@ -54,6 +56,7 @@ export default function SettingsPage() {
|
|||||||
const [testingBark, setTestingBark] = useState(false);
|
const [testingBark, setTestingBark] = useState(false);
|
||||||
const [testingEmail, setTestingEmail] = useState(false);
|
const [testingEmail, setTestingEmail] = useState(false);
|
||||||
const [testingWecom, setTestingWecom] = useState(false);
|
const [testingWecom, setTestingWecom] = useState(false);
|
||||||
|
const [testingLark, setTestingLark] = useState(false);
|
||||||
const [changingPassword, setChangingPassword] = useState(false);
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
const [applyingUpdate, setApplyingUpdate] = useState(false);
|
const [applyingUpdate, setApplyingUpdate] = useState(false);
|
||||||
@@ -265,10 +268,11 @@ export default function SettingsPage() {
|
|||||||
setSavingNotif(true);
|
setSavingNotif(true);
|
||||||
try {
|
try {
|
||||||
// vocat 后端 PUT 成功即返回完整配置文档(参考实现返回 {applied, warning})
|
// vocat 后端 PUT 成功即返回完整配置文档(参考实现返回 {applied, warning})
|
||||||
await api("/settings/notifications", {
|
const data = await api<NotificationSettings>("/settings/notifications", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: buildNotificationsPayload(forms),
|
body: buildNotificationsPayload(forms),
|
||||||
});
|
});
|
||||||
|
setForms(formsFromNotifications(data));
|
||||||
message.success(t("通知配置已保存"));
|
message.success(t("通知配置已保存"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(apiMessage(error) || t("通知配置保存失败"));
|
message.error(apiMessage(error) || t("通知配置保存失败"));
|
||||||
@@ -338,6 +342,21 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [forms.wecom]);
|
}, [forms.wecom]);
|
||||||
|
|
||||||
|
const onTestLark = useCallback(async () => {
|
||||||
|
setTestingLark(true);
|
||||||
|
try {
|
||||||
|
await api("/settings/notifications/lark/test", {
|
||||||
|
method: "POST",
|
||||||
|
body: buildLarkPayload(forms.lark, true),
|
||||||
|
});
|
||||||
|
message.success(t("测试通知已发送"));
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiMessage(error) || t("飞书 / Lark 群机器人通知测试失败"));
|
||||||
|
} finally {
|
||||||
|
setTestingLark(false);
|
||||||
|
}
|
||||||
|
}, [forms.lark]);
|
||||||
|
|
||||||
const onCheckUpdate = useCallback(async () => {
|
const onCheckUpdate = useCallback(async () => {
|
||||||
setCheckingUpdate(true);
|
setCheckingUpdate(true);
|
||||||
try {
|
try {
|
||||||
@@ -466,7 +485,7 @@ export default function SettingsPage() {
|
|||||||
<CardIcon>
|
<CardIcon>
|
||||||
<AlertRegular className="text-[24px]" />
|
<AlertRegular className="text-[24px]" />
|
||||||
</CardIcon>
|
</CardIcon>
|
||||||
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送")} />
|
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook / 企业微信 / 飞书 / Lark 群机器人")} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" loading={savingNotif} disabled={loadingNotif} onClick={onSaveNotifications} className="!border-0" icon={<CheckmarkRegular />}>
|
<Button variant="primary" loading={savingNotif} disabled={loadingNotif} onClick={onSaveNotifications} className="!border-0" icon={<CheckmarkRegular />}>
|
||||||
{t("保存通知配置")}
|
{t("保存通知配置")}
|
||||||
@@ -500,6 +519,9 @@ export default function SettingsPage() {
|
|||||||
{activeTab === "wecom" ? (
|
{activeTab === "wecom" ? (
|
||||||
<WecomTab value={forms.wecom} onChange={(p) => updateChannel("wecom", p)} testing={testingWecom} onTest={onTestWecom} />
|
<WecomTab value={forms.wecom} onChange={(p) => updateChannel("wecom", p)} testing={testingWecom} onTest={onTestWecom} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeTab === "lark" ? (
|
||||||
|
<LarkTab value={forms.lark} onChange={(p) => updateChannel("lark", p)} testing={testingLark} onTest={onTestLark} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -330,8 +330,13 @@ export interface UpstreamProxyProbe {
|
|||||||
reachable?: boolean;
|
reachable?: boolean;
|
||||||
handshakeOk?: boolean;
|
handshakeOk?: boolean;
|
||||||
udpAssociateOk?: boolean;
|
udpAssociateOk?: boolean;
|
||||||
|
udpExchangeOk?: boolean;
|
||||||
authMethod?: string;
|
authMethod?: string;
|
||||||
relayAddr?: string;
|
relayAddr?: string;
|
||||||
|
dnsServer?: string;
|
||||||
|
dnsName?: string;
|
||||||
|
dnsRcode?: number;
|
||||||
|
roundTripMs?: number;
|
||||||
diagnosis?: string;
|
diagnosis?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -415,6 +420,7 @@ export interface NotificationSettings {
|
|||||||
email: Record<string, unknown>;
|
email: Record<string, unknown>;
|
||||||
pushplus: Record<string, unknown>;
|
pushplus: Record<string, unknown>;
|
||||||
wecom: Record<string, unknown>;
|
wecom: Record<string, unknown>;
|
||||||
|
lark: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。
|
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const source = await readFile(new URL("../src/components/settings/model.ts", import.meta.url), "utf8");
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.ES2022,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const moduleURL = `data:text/javascript;base64,${Buffer.from(compiled.outputText).toString("base64")}`;
|
||||||
|
const {
|
||||||
|
DEFAULT_LARK_PAYLOAD_TEMPLATE,
|
||||||
|
buildLarkPayload,
|
||||||
|
buildNotificationsPayload,
|
||||||
|
formsFromNotifications,
|
||||||
|
} = await import(moduleURL);
|
||||||
|
|
||||||
|
test("loads a masked signed Lark group bot config", () => {
|
||||||
|
const forms = formsFromNotifications({
|
||||||
|
lark: {
|
||||||
|
enabled: true,
|
||||||
|
url: "********",
|
||||||
|
signingEnabled: true,
|
||||||
|
secret: "********",
|
||||||
|
payloadTemplate: '{"msg_type":"text"}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(forms.lark, {
|
||||||
|
enabled: true,
|
||||||
|
url: "********",
|
||||||
|
signingEnabled: true,
|
||||||
|
secret: "********",
|
||||||
|
payloadTemplate: '{"msg_type":"text"}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not expose a dormant signing secret when signing is disabled", () => {
|
||||||
|
const forms = formsFromNotifications({
|
||||||
|
lark: {
|
||||||
|
enabled: true,
|
||||||
|
url: "********",
|
||||||
|
signingEnabled: false,
|
||||||
|
secret: "********",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(forms.lark.secret, "");
|
||||||
|
assert.equal(forms.lark.payloadTemplate, DEFAULT_LARK_PAYLOAD_TEMPLATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds the single group bot webhook contract", () => {
|
||||||
|
const signed = buildLarkPayload({
|
||||||
|
enabled: true,
|
||||||
|
url: " https://open.larksuite.com/open-apis/bot/v2/hook/token ",
|
||||||
|
signingEnabled: true,
|
||||||
|
secret: "demo",
|
||||||
|
payloadTemplate: '{"msg_type":"text"}',
|
||||||
|
}, true);
|
||||||
|
assert.deepEqual(signed, {
|
||||||
|
enabled: true,
|
||||||
|
url: "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||||
|
signing_enabled: true,
|
||||||
|
secret: "demo",
|
||||||
|
payload_template: '{"msg_type":"text"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsigned = buildLarkPayload({
|
||||||
|
enabled: false,
|
||||||
|
url: "",
|
||||||
|
signingEnabled: false,
|
||||||
|
secret: "stale-secret",
|
||||||
|
payloadTemplate: DEFAULT_LARK_PAYLOAD_TEMPLATE,
|
||||||
|
});
|
||||||
|
assert.equal(Object.hasOwn(unsigned, "url"), false);
|
||||||
|
assert.equal(Object.hasOwn(unsigned, "secret"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes Lark in the complete notification settings payload", () => {
|
||||||
|
const forms = formsFromNotifications({});
|
||||||
|
const payload = buildNotificationsPayload(forms);
|
||||||
|
|
||||||
|
assert.deepEqual(payload.lark, {
|
||||||
|
enabled: false,
|
||||||
|
signing_enabled: false,
|
||||||
|
payload_template: DEFAULT_LARK_PAYLOAD_TEMPLATE,
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user