mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 23:33:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63553eaf2b | ||
|
|
72e0af6eb9 | ||
|
|
c66fe06def | ||
|
|
489a6dc10c | ||
|
|
9c39e15bcf | ||
|
|
3b8f32f591 | ||
|
|
0318670f49 | ||
|
|
d06afdb076 | ||
|
|
b56acc0e3a | ||
|
|
60cc636969 | ||
|
|
73a72680ad | ||
|
|
60501d4831 | ||
|
|
2c843d82a4 | ||
|
|
ad66456d2f | ||
|
|
161aa667c9 | ||
|
|
8137fc875b | ||
|
|
1df338f9b3 | ||
|
|
20f91fac72 | ||
|
|
30880f6612 | ||
|
|
fdb2ac45ce | ||
|
|
95299a0a39 | ||
|
|
1a4032d013 | ||
|
|
6ec950bfd2 | ||
|
|
04dd026e95 | ||
|
|
a2a72dfd30 | ||
|
|
67079c99b9 | ||
|
|
f949001480 | ||
|
|
fa8afb9571 | ||
|
|
bfda29193a | ||
|
|
ed64fd428a | ||
|
|
f84a1f99b1 |
@@ -49,13 +49,13 @@ jobs:
|
||||
BUILD_TIME=${{ github.event.repository.updated_at }}
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
|
||||
- name: Verify ${{ matrix.platform }} runtime, QMI, and smart-card stack
|
||||
run: |
|
||||
docker run --rm --platform '${{ matrix.platform }}' \
|
||||
vocat-smoke:${{ matrix.arch }} version
|
||||
docker run --rm --platform '${{ matrix.platform }}' \
|
||||
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \
|
||||
'command -v pcscd && test -d /usr/lib/pcsc/drivers'
|
||||
'command -v qmicli && command -v qmi-network && command -v pcscd && test -d /usr/lib/pcsc/drivers'
|
||||
|
||||
build-and-push:
|
||||
needs: smoke
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
||||
|
||||
# ---- Stage 3: minimal runtime ----
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \
|
||||
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite qmi-utils tzdata && \
|
||||
addgroup -S -g 1000 vocat && \
|
||||
adduser -S -D -H -u 1000 -G vocat vocat
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
Copyright (c) 2013, Dustin L. Howett. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The views and conclusions contained in the software and documentation are
|
||||
those of the authors and should not be interpreted as representing official
|
||||
policies, either expressed or implied, of the FreeBSD Project.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Parts of this package were made available under the license covering the Go
|
||||
language and all attended core libraries. That license follows.
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -96,6 +96,14 @@ to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
If matching kernel modules are unavailable, use a firmware that includes them;
|
||||
never force-install kmods built for a different kernel.
|
||||
|
||||
If your kernel cannot provide XFRM/IPsec and you only need non-VoWiFi features
|
||||
such as cellular SMS or data, install with `--skip-vowifi-check`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh --skip-vowifi-check
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
|
||||
@@ -188,6 +196,11 @@ those fixed nodes and does not provide complete multi-device or hot-plug discove
|
||||
|
||||
The GHCR image is published for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / QNAP Container Station Deployment Note**:
|
||||
> On NAS operating systems like QNAP QTS / QuTS hero (Container Station), custom non-root administrator accounts and volume isolation mechanisms may cause Docker named volumes (e.g. `-v vocat-data:/opt/vocat/data`) to resolve to different isolated paths between the one-off `bootstrap-admin` initialization and the daemon service container, leading to "Incorrect password" errors during Web login.
|
||||
> For NAS environments, it is strongly recommended to replace named volumes with a host absolute path bind mount (e.g. `-v /share/Container/vocat/data:/opt/vocat/data` on QNAP) for both initialization and runtime to guarantee consistent SQLite database persistence.
|
||||
|
||||
### USB SIM readers
|
||||
|
||||
USB SIM readers use the Linux PC/SC service. The one-click installer installs
|
||||
@@ -197,6 +210,18 @@ managers. On Debian/Ubuntu, the equivalent manual setup is
|
||||
VoCat keeps the reader visible in the add-device dialog and reports the missing
|
||||
service or driver instead of silently hiding it.
|
||||
|
||||
### QMI command-line utilities
|
||||
|
||||
VoCat uses `qmicli` to verify that a QMI control channel is ready and
|
||||
`qmi-network` to manage packet-data sessions. The one-click installer installs
|
||||
and verifies the corresponding utilities automatically. For manual deployment,
|
||||
Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses
|
||||
`pacman -S libqmi`, 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
|
||||
|
||||
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
|
||||
@@ -212,6 +237,10 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
||||
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
||||
|
||||
User-supplied Apple carrier bundles can be converted into reviewable,
|
||||
allow-listed carrier profiles with `vocat carrier import-ipcc`; see
|
||||
[docs/CARRIER_IPCC_IMPORT.md](docs/CARRIER_IPCC_IMPORT.md).
|
||||
|
||||
Administrator credentials are stored only in SQLite. Initialize an empty
|
||||
database once with `vocat bootstrap-admin`; environment variables and JSON
|
||||
configuration cannot set or overwrite the administrator username or password.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func runCarrier(args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: vocat carrier import-ipcc [flags] FILE.ipcc")
|
||||
}
|
||||
switch args[0] {
|
||||
case "import-ipcc":
|
||||
return runCarrierImportIPCC(args[1:], stdout)
|
||||
default:
|
||||
return fmt.Errorf("unknown carrier subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runCarrierImportIPCC(args []string, stdout io.Writer) error {
|
||||
flags := flag.NewFlagSet("carrier import-ipcc", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
var bundle string
|
||||
var profileID string
|
||||
var profileDir string
|
||||
var install bool
|
||||
var documentOnly bool
|
||||
flags.StringVar(&bundle, "bundle", "", "bundle name when an IPCC contains more than one carrier bundle")
|
||||
flags.StringVar(&profileID, "id", "", "override the generated carrier profile ID")
|
||||
flags.StringVar(&profileDir, "profile-dir", "", "installation directory (default: next to the VoCat database)")
|
||||
flags.BoolVar(&install, "install", false, "atomically install the reviewed generated profile")
|
||||
flags.BoolVar(&documentOnly, "document-only", false, "print only the generated carrier profile document")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 1 {
|
||||
return errors.New("usage: vocat carrier import-ipcc [--bundle NAME] [--id ID] [--document-only] [--install] [--profile-dir DIR] FILE.ipcc")
|
||||
}
|
||||
if documentOnly && install {
|
||||
return errors.New("--document-only and --install cannot be used together")
|
||||
}
|
||||
if strings.TrimSpace(profileDir) != "" && !install {
|
||||
return errors.New("--profile-dir requires --install")
|
||||
}
|
||||
result, err := vowifi.ImportCarrierIPCC(flags.Arg(0), vowifi.IPCCImportOptions{
|
||||
Bundle: bundle,
|
||||
ProfileID: profileID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if documentOnly {
|
||||
_, err := stdout.Write(result.Document)
|
||||
return err
|
||||
}
|
||||
|
||||
installedPath := ""
|
||||
if install {
|
||||
profileDir = strings.TrimSpace(profileDir)
|
||||
if profileDir == "" {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration for carrier profile directory: %w", err)
|
||||
}
|
||||
profileDir = filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||
}
|
||||
installedPath, err = vowifi.InstallCarrierIPCCResult(result, profileDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absolute, absoluteErr := filepath.Abs(installedPath); absoluteErr == nil {
|
||||
installedPath = absolute
|
||||
}
|
||||
}
|
||||
output := struct {
|
||||
vowifi.IPCCImportResult
|
||||
InstalledPath string `json:"installed_path,omitempty"`
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}{
|
||||
IPCCImportResult: result,
|
||||
InstalledPath: installedPath,
|
||||
RestartRequired: installedPath != "",
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(output)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
func TestRunCarrierImportIPCCPreviewsAndInstallsExplicitly(t *testing.T) {
|
||||
archivePath := filepath.Join(t.TempDir(), "test.ipcc")
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(file)
|
||||
entry, err := archive.Create("Payload/Test.bundle/carrier.plist")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := plist.NewEncoder(&encoded).Encode(map[string]any{
|
||||
"CarrierName": "Test Carrier",
|
||||
"SupportedSIMs": []any{"99901"},
|
||||
"SupportedPLMNs": []any{"99901"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var preview bytes.Buffer
|
||||
if err := runCarrier([]string{"import-ipcc", "--document-only", archivePath}, &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(preview.Bytes(), &document); err != nil || document.Version != 1 {
|
||||
t.Fatalf("preview = %q, version=%d, error=%v", preview.String(), document.Version, err)
|
||||
}
|
||||
|
||||
installDir := t.TempDir()
|
||||
var output bytes.Buffer
|
||||
if err := runCarrier([]string{
|
||||
"import-ipcc", "--id", "cli-test", "--install", "--profile-dir", installDir, archivePath,
|
||||
}, &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var installed struct {
|
||||
InstalledPath string `json:"installed_path"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
}
|
||||
if err := json.Unmarshal(output.Bytes(), &installed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installed.RestartRequired || filepath.Base(installed.InstalledPath) != "cli-test.json" {
|
||||
t.Fatalf("install output = %s", output.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "cli-test.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,16 @@ Usage:
|
||||
vocat serve Run the server in the foreground (use from a TTY when
|
||||
vocat without arguments would enter the menu).
|
||||
vocat version Print the build version and exit.
|
||||
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths.
|
||||
Use --repair-dji-qmi on Linux to restore the factory-ID
|
||||
DJI/Baiwang 2ca3:4006 AT/QMI interface bindings and wake
|
||||
QMI without changing NV.
|
||||
vocat carrier import-ipcc [flags] FILE.ipcc
|
||||
Convert an Apple carrier bundle into a reviewable VoCat
|
||||
profile. Preview is the default; --install writes it to
|
||||
carrier-profiles.d and takes effect after restart.
|
||||
Flags: --bundle NAME --id ID --document-only --install
|
||||
--profile-dir DIR.
|
||||
vocat update Check GitHub for a newer release and self-update.
|
||||
Flags:
|
||||
--check Only report whether an update is available.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
"vocat/internal/proxy"
|
||||
)
|
||||
|
||||
type doctorCheck struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Evidence any `json:"evidence,omitempty"`
|
||||
}
|
||||
|
||||
type doctorReport struct {
|
||||
Time time.Time `json:"time"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Checks []doctorCheck `json:"checks"`
|
||||
}
|
||||
|
||||
type djiQMIRepairResult struct {
|
||||
USBName string `json:"usb_name"`
|
||||
Interface string `json:"interface"`
|
||||
USBDevice string `json:"usb_device"`
|
||||
OriginalDriver string `json:"original_driver,omitempty"`
|
||||
SerialInterfaces []string `json:"serial_interfaces,omitempty"`
|
||||
SerialDevices []string `json:"serial_devices,omitempty"`
|
||||
ATDevice string `json:"at_device,omitempty"`
|
||||
ControlDevice string `json:"control_device"`
|
||||
NetworkInterface string `json:"network_interface,omitempty"`
|
||||
QMIProbe string `json:"qmi_probe"`
|
||||
Attempts int `json:"attempts"`
|
||||
}
|
||||
|
||||
func runDoctor(args []string) error {
|
||||
flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
flags.SetOutput(os.Stderr)
|
||||
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
|
||||
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
|
||||
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password")
|
||||
repairDJI := flags.Bool("repair-dji-qmi", false, "bind DJI 2ca3:4006 interfaces 0-3 to option and interface 4 to qmi_wwan, then assert DTR (Linux/root only; no NV write)")
|
||||
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
|
||||
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 || *timeout <= 0 || *timeout > time.Minute {
|
||||
return errors.New("usage: vocat doctor [--repair-dji-qmi] [--proxy host:port] [--proxy-username name] [--proxy-password-env ENV] [--json]")
|
||||
}
|
||||
report := doctorReport{Time: time.Now().UTC(), OS: runtime.GOOS, Arch: runtime.GOARCH}
|
||||
add := func(name, status, code, message string, evidence any) {
|
||||
report.Checks = append(report.Checks, doctorCheck{Name: name, Status: status, Code: code, Message: message, Evidence: evidence})
|
||||
}
|
||||
|
||||
if data, err := os.ReadFile("/proc/version"); err == nil && strings.Contains(strings.ToLower(string(data)), "microsoft") {
|
||||
add("host", "warning", "wsl_usbip_detected", "WSL/USBIP detected; QMI control transfers may time out even when /dev/cdc-wdm exists", nil)
|
||||
} else {
|
||||
add("host", "passed", "native_host", "No WSL kernel marker detected", nil)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
if *repairDJI {
|
||||
result, err := repairDJIQMI(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair DJI QMI binding: %w", err)
|
||||
}
|
||||
add("dji_qmi_repair", "passed", "dji_usb_interfaces_repaired", "DJI serial interfaces 0-3 were bound to option and interface 4 to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result)
|
||||
}
|
||||
|
||||
candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx)
|
||||
if discoverErr != nil {
|
||||
add("modem_discovery", "failed", "modem_discovery_failed", discoverErr.Error(), nil)
|
||||
} else if len(candidates) == 0 {
|
||||
add("modem_discovery", "warning", "no_modem", "No USB modem was discovered", nil)
|
||||
} else {
|
||||
add("modem_discovery", "passed", "modem_discovered", fmt.Sprintf("Discovered %d modem candidate(s)", len(candidates)), candidates)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
name := "modem:" + candidate.ID
|
||||
if candidate.HasATPort() {
|
||||
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 5*time.Second))
|
||||
client, openErr := (modem.SerialOpener{}).Open(probeContext, candidate.ATPort)
|
||||
if openErr != nil {
|
||||
add(name+":at", "warning", "at_open_failed", openErr.Error(), candidate.ATPort.OpenPath())
|
||||
} else {
|
||||
response, commandErr := client.Execute(probeContext, "AT+CFUN?")
|
||||
_ = client.Close()
|
||||
if commandErr != nil {
|
||||
add(name+":at", "warning", "at_probe_failed", commandErr.Error(), candidate.ATPort.OpenPath())
|
||||
} else {
|
||||
add(name+":at", "passed", "at_ready", "AT control channel responded to a read-only CFUN query", response.Text())
|
||||
}
|
||||
}
|
||||
cancelProbe()
|
||||
} else {
|
||||
add(name+":at", "failed", "at_missing", "No AT port was selected", nil)
|
||||
}
|
||||
if strings.TrimSpace(candidate.QMIControl) == "" {
|
||||
add(name+":qmi", "warning", "qmi_missing", "No cdc-wdm/QMI control node was discovered", nil)
|
||||
} else if qmicli, lookErr := exec.LookPath("qmicli"); lookErr != nil {
|
||||
add(name+":qmi", "warning", "qmicli_missing", "QMI node exists but qmicli is unavailable for an active DMS check", candidate.QMIControl)
|
||||
} else {
|
||||
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 8*time.Second))
|
||||
command := exec.CommandContext(probeContext, qmicli, "-d", candidate.QMIControl, "--dms-get-operating-mode")
|
||||
output, commandErr := command.CombinedOutput()
|
||||
message := strings.TrimSpace(string(output))
|
||||
cancelProbe()
|
||||
if commandErr != nil {
|
||||
code := "qmi_cid_failed"
|
||||
if errors.Is(probeContext.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timed out") {
|
||||
code = "qmi_cid_timeout"
|
||||
}
|
||||
add(name+":qmi", "failed", code, "qmicli DMS client allocation/read failed", message)
|
||||
} else {
|
||||
add(name+":qmi", "passed", "qmi_dms_ready", "qmicli allocated DMS and completed a read-only request", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readers, readerErr := pcsc.New().Readers(ctx)
|
||||
if readerErr == nil {
|
||||
add("pcsc", "passed", "pcsc_ready", fmt.Sprintf("PC/SC reported %d reader(s)", len(readers)), readers)
|
||||
} else if errors.Is(readerErr, pcsc.ErrUnsupported) || errors.Is(readerErr, pcsc.ErrUnavailable) {
|
||||
add("pcsc", "warning", "pcsc_unavailable", readerErr.Error(), nil)
|
||||
} else {
|
||||
add("pcsc", "failed", "pcsc_failed", readerErr.Error(), nil)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(*proxyAddress) != "" {
|
||||
password := os.Getenv(strings.TrimSpace(*passwordEnv))
|
||||
probeContext, cancelProbe := context.WithTimeout(context.Background(), *timeout)
|
||||
result, probeErr := proxy.ProbeSOCKS5(probeContext, *proxyAddress, *proxyUsername, password, *timeout)
|
||||
cancelProbe()
|
||||
status := "passed"
|
||||
if probeErr != nil {
|
||||
status = "failed"
|
||||
}
|
||||
add("proxy_udp", status, result.Diagnosis, result.Hint, result)
|
||||
}
|
||||
|
||||
if *jsonOutput {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(report)
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
fmt.Printf("%-8s %-26s %-28s %s\n", strings.ToUpper(check.Status), check.Name, check.Code, check.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func minDuration(left, right time.Duration) time.Duration {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
djiVendorID = "2ca3"
|
||||
djiProductID = "4006"
|
||||
djiFirstSerialIndex = 0
|
||||
djiLastSerialIndex = 3
|
||||
djiATIndex = 2
|
||||
djiQMIIndex = 4
|
||||
)
|
||||
|
||||
type usbControlTransfer struct {
|
||||
RequestType uint8
|
||||
Request uint8
|
||||
Value uint16
|
||||
Index uint16
|
||||
Length uint16
|
||||
Timeout uint32
|
||||
Data uintptr
|
||||
}
|
||||
|
||||
func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) {
|
||||
qmicli, err := exec.LookPath("qmicli")
|
||||
if err != nil {
|
||||
return djiQMIRepairResult{}, errors.New("qmicli is required to verify DJI QMI readiness; install libqmi-utils on Debian/Ubuntu/Fedora, libqmi on Arch Linux, or qmi-utils on Alpine")
|
||||
}
|
||||
return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) {
|
||||
return repairDJIQMIAt(attemptContext, "/sys", "/dev", qmicli)
|
||||
})
|
||||
}
|
||||
|
||||
func retryDJIQMI(
|
||||
ctx context.Context,
|
||||
maxAttempts int,
|
||||
delay time.Duration,
|
||||
attempt func(context.Context) (djiQMIRepairResult, error),
|
||||
) (djiQMIRepairResult, error) {
|
||||
var result djiQMIRepairResult
|
||||
var err error
|
||||
for attemptNumber := 1; attemptNumber <= maxAttempts; attemptNumber++ {
|
||||
result, err = attempt(ctx)
|
||||
result.Attempts = attemptNumber
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(attemptNumber) * delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return result, errors.Join(err, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err)
|
||||
}
|
||||
|
||||
func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (result djiQMIRepairResult, returnErr error) {
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
entries, err := os.ReadDir(usbRoot)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read USB topology: %w", err)
|
||||
}
|
||||
var usbNames []string
|
||||
for _, entry := range entries {
|
||||
devicePath := filepath.Join(usbRoot, entry.Name())
|
||||
vendor, vendorErr := readTrimmedFile(filepath.Join(devicePath, "idVendor"))
|
||||
product, productErr := readTrimmedFile(filepath.Join(devicePath, "idProduct"))
|
||||
if vendorErr == nil && productErr == nil &&
|
||||
strings.EqualFold(vendor, djiVendorID) && strings.EqualFold(product, djiProductID) {
|
||||
usbNames = append(usbNames, entry.Name())
|
||||
}
|
||||
}
|
||||
if len(usbNames) != 1 {
|
||||
return result, fmt.Errorf("expected exactly one DJI %s:%s USB device, found %d", djiVendorID, djiProductID, len(usbNames))
|
||||
}
|
||||
result.USBName = usbNames[0]
|
||||
result.Interface = fmt.Sprintf("%s:1.%d", result.USBName, djiQMIIndex)
|
||||
devicePath := filepath.Join(usbRoot, result.USBName)
|
||||
interfacePath := filepath.Join(usbRoot, result.Interface)
|
||||
if _, err := os.Stat(interfacePath); err != nil {
|
||||
return result, fmt.Errorf("DJI QMI interface %s unavailable: %w", result.Interface, err)
|
||||
}
|
||||
|
||||
busNumber, err := readUSBNumber(filepath.Join(devicePath, "busnum"))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
deviceNumber, err := readUSBNumber(filepath.Join(devicePath, "devnum"))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber))
|
||||
|
||||
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||
if err := ensureUSBDriverLoaded(ctx, driversRoot, "qmi_wwan", "qmi_wwan"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := ensureUSBDriverLoaded(ctx, driversRoot, "option", "option"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// qmi_wwan's USB dynamic ID is device-wide. Leaving it installed makes it
|
||||
// probe every vendor-specific interface after a USBIP reconnect; on this DJI
|
||||
// composition that can turn interfaces 1-3 into bogus cdc-wdm devices and
|
||||
// remove the AT port. Remove it before detaching anything, then add it only
|
||||
// briefly below while interface 4 is the sole unbound interface.
|
||||
qmiDriverRoot := filepath.Join(driversRoot, "qmi_wwan")
|
||||
if err := removeDynamicUSBID(qmiDriverRoot, djiVendorID+" "+djiProductID); err != nil {
|
||||
return result, fmt.Errorf("remove broad DJI qmi_wwan dynamic ID: %w", err)
|
||||
}
|
||||
|
||||
serialInterfaces, serialDevices, atDevice, err := bindDJISerialInterfaces(ctx, sysRoot, devRoot, usbRoot, driversRoot, result.USBName)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SerialInterfaces = serialInterfaces
|
||||
result.SerialDevices = serialDevices
|
||||
result.ATDevice = atDevice
|
||||
|
||||
result.OriginalDriver = usbInterfaceDriver(interfacePath)
|
||||
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
|
||||
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver)
|
||||
}
|
||||
|
||||
interfaceDetached := false
|
||||
restoreOriginal := func() {
|
||||
if !interfaceDetached {
|
||||
return
|
||||
}
|
||||
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
||||
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
|
||||
}
|
||||
switch result.OriginalDriver {
|
||||
case "qmi_wwan":
|
||||
_ = bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface)
|
||||
case "option":
|
||||
_ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if returnErr != nil {
|
||||
restoreOriginal()
|
||||
}
|
||||
}()
|
||||
if result.OriginalDriver != "" {
|
||||
if err := writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "unbind"), result.Interface); err != nil {
|
||||
return result, fmt.Errorf("unbind %s from %s: %w", result.OriginalDriver, result.Interface, err)
|
||||
}
|
||||
interfaceDetached = true
|
||||
}
|
||||
if err := assertUSBDTR(result.USBDevice, djiQMIIndex); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
result.ControlDevice = firstDeviceNode(filepath.Join(interfacePath, "usbmisc"), devRoot, "cdc-wdm")
|
||||
result.NetworkInterface = firstEntryName(filepath.Join(interfacePath, "net"), "")
|
||||
if result.ControlDevice != "" {
|
||||
break
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return result, fmt.Errorf("qmi_wwan bound but no cdc-wdm node appeared for %s", result.Interface)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
// The requested driver topology is now established. A later DMS timeout is
|
||||
// a QMI/USBIP readiness problem, so do not roll interface 4 back to option.
|
||||
interfaceDetached = false
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
|
||||
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
||||
probeContextErr := probeContext.Err()
|
||||
cancelProbe()
|
||||
result.QMIProbe = strings.TrimSpace(string(output))
|
||||
if probeErr != nil {
|
||||
if probeContextErr != nil {
|
||||
probeErr = errors.Join(probeErr, probeContextErr)
|
||||
}
|
||||
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func bindDJIQMIInterface(driverRoot, interfacePath, interfaceName string) (returnErr error) {
|
||||
bindPath := filepath.Join(driverRoot, "bind")
|
||||
dynamicIDAdded := false
|
||||
defer func() {
|
||||
if dynamicIDAdded {
|
||||
removeErr := removeDynamicUSBID(driverRoot, djiVendorID+" "+djiProductID)
|
||||
if returnErr == nil && removeErr != nil {
|
||||
returnErr = fmt.Errorf("remove temporary DJI qmi_wwan dynamic ID: %w", removeErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := writeSysfs(bindPath, interfaceName); err != nil {
|
||||
newIDErr := writeSysfs(filepath.Join(driverRoot, "new_id"), djiVendorID+" "+djiProductID)
|
||||
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
|
||||
return fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
|
||||
}
|
||||
dynamicIDAdded = true
|
||||
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
|
||||
if retryErr := writeSysfs(bindPath, interfaceName); retryErr != nil {
|
||||
return fmt.Errorf("bind qmi_wwan to %s: %w", interfaceName, retryErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
|
||||
return fmt.Errorf("interface %s driver is %q after qmi_wwan bind", interfaceName, driver)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureUSBDriverLoaded(ctx context.Context, driversRoot, driverName, moduleName string) error {
|
||||
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("inspect %s driver: %w", driverName, err)
|
||||
}
|
||||
modprobe, err := exec.LookPath("modprobe")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is not loaded and modprobe is unavailable", driverName)
|
||||
}
|
||||
if output, loadErr := exec.CommandContext(ctx, modprobe, moduleName).CombinedOutput(); loadErr != nil {
|
||||
return fmt.Errorf("load %s: %w: %s", moduleName, loadErr, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err != nil {
|
||||
return fmt.Errorf("%s driver is unavailable after loading module %s: %w", driverName, moduleName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindDJISerialInterfaces(
|
||||
ctx context.Context,
|
||||
sysRoot, devRoot, usbRoot, driversRoot, usbName string,
|
||||
) ([]string, []string, string, error) {
|
||||
interfaceNames := make([]string, 0, djiLastSerialIndex-djiFirstSerialIndex+1)
|
||||
interfacePaths := make([]string, 0, cap(interfaceNames))
|
||||
needsDynamicID := false
|
||||
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||
name := fmt.Sprintf("%s:1.%d", usbName, index)
|
||||
path := filepath.Join(usbRoot, name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("DJI serial interface %s unavailable: %w", name, err)
|
||||
}
|
||||
driver := usbInterfaceDriver(path)
|
||||
if driver != "" && driver != "option" && driver != "qmi_wwan" {
|
||||
return nil, nil, "", fmt.Errorf("refusing to replace unexpected driver %q on %s", driver, name)
|
||||
}
|
||||
interfaceNames = append(interfaceNames, name)
|
||||
interfacePaths = append(interfacePaths, path)
|
||||
needsDynamicID = needsDynamicID || driver != "option"
|
||||
}
|
||||
|
||||
if needsDynamicID {
|
||||
// Detach every false QMI claim before option's new_id triggers probing.
|
||||
for index, path := range interfacePaths {
|
||||
if usbInterfaceDriver(path) != "qmi_wwan" {
|
||||
continue
|
||||
}
|
||||
if err := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "unbind"), interfaceNames[index]); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("unbind qmi_wwan from serial interface %s: %w", interfaceNames[index], err)
|
||||
}
|
||||
}
|
||||
|
||||
optionSerialRoot := filepath.Join(sysRoot, "bus", "usb-serial", "drivers", "option1")
|
||||
if _, err := os.Stat(optionSerialRoot); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("option USB-serial driver is unavailable: %w", err)
|
||||
}
|
||||
if err := writeSysfs(filepath.Join(optionSerialRoot, "new_id"), djiVendorID+" "+djiProductID); err != nil && !errors.Is(err, syscall.EEXIST) {
|
||||
return nil, nil, "", fmt.Errorf("register DJI option dynamic ID: %w", err)
|
||||
}
|
||||
|
||||
for index, path := range interfacePaths {
|
||||
if usbInterfaceDriver(path) == "option" {
|
||||
continue
|
||||
}
|
||||
if err := writeSysfs(filepath.Join(driversRoot, "option", "bind"), interfaceNames[index]); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("bind option to %s: %w", interfaceNames[index], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index, path := range interfacePaths {
|
||||
if driver := usbInterfaceDriver(path); driver != "option" {
|
||||
return nil, nil, "", fmt.Errorf("serial interface %s driver is %q after option bind", interfaceNames[index], driver)
|
||||
}
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
serialDevices := make([]string, len(interfacePaths))
|
||||
for {
|
||||
complete := true
|
||||
for index, path := range interfacePaths {
|
||||
name := firstEntryName(path, "ttyUSB")
|
||||
if name == "" {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
serialDevices[index] = filepath.Join(devRoot, name)
|
||||
}
|
||||
if complete {
|
||||
break
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, nil, "", fmt.Errorf("option bound but not all ttyUSB nodes appeared for %s", usbName)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
return interfaceNames, serialDevices, serialDevices[djiATIndex-djiFirstSerialIndex], nil
|
||||
}
|
||||
|
||||
func removeDynamicUSBID(driverRoot, id string) error {
|
||||
path := filepath.Join(driverRoot, "remove_id")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := writeSysfs(path, id); err != nil && !errors.Is(err, syscall.ENODEV) && !errors.Is(err, syscall.ENOENT) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertUSBDTR(devicePath string, interfaceIndex int) error {
|
||||
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open USB device %s: %w", devicePath, err)
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
if err := setUSBControlLineState(fd, interfaceIndex, false); err != nil {
|
||||
return fmt.Errorf("clear CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if err := setUSBControlLineState(fd, interfaceIndex, true); err != nil {
|
||||
return fmt.Errorf("assert CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
||||
}
|
||||
// QDC507 acknowledges the control transfer before its QMI firmware is ready.
|
||||
time.Sleep(time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setUSBControlLineState(fd, interfaceIndex int, dtr bool) error {
|
||||
var value uint16
|
||||
if dtr {
|
||||
value = 1 // USB_CDC_CTRL_DTR
|
||||
}
|
||||
transfer := usbControlTransfer{
|
||||
RequestType: 0x21, // host-to-device, class, interface
|
||||
Request: 0x22, // USB_CDC_REQ_SET_CONTROL_LINE_STATE
|
||||
Value: value,
|
||||
Index: uint16(interfaceIndex),
|
||||
Timeout: 5000,
|
||||
}
|
||||
const ioctlDirectionReadWrite = uintptr(3)
|
||||
request := ioctlDirectionReadWrite<<30 |
|
||||
uintptr(unsafe.Sizeof(transfer))<<16 |
|
||||
uintptr('U')<<8
|
||||
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), request, uintptr(unsafe.Pointer(&transfer)))
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readTrimmedFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func readUSBNumber(path string) (int, error) {
|
||||
value, err := readTrimmedFile(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
number, err := strconv.Atoi(value)
|
||||
if err != nil || number < 1 || number > 999 {
|
||||
return 0, fmt.Errorf("invalid %s %q", filepath.Base(path), value)
|
||||
}
|
||||
return number, nil
|
||||
}
|
||||
|
||||
func usbInterfaceDriver(interfacePath string) string {
|
||||
resolved, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(resolved)
|
||||
}
|
||||
|
||||
func writeSysfs(path, value string) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := file.WriteString(value)
|
||||
closeErr := file.Close()
|
||||
return errors.Join(writeErr, closeErr)
|
||||
}
|
||||
|
||||
func firstDeviceNode(directory, devRoot, prefix string) string {
|
||||
name := firstEntryName(directory, prefix)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(devRoot, name)
|
||||
}
|
||||
|
||||
func firstEntryName(directory, prefix string) string {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), prefix) {
|
||||
return entry.Name()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func TestDJIUSBControlTransferLayout(t *testing.T) {
|
||||
var transfer usbControlTransfer
|
||||
if got := unsafe.Sizeof(transfer); got != 24 {
|
||||
t.Fatalf("usbControlTransfer size = %d, want 24", got)
|
||||
}
|
||||
if transfer.RequestType != 0 || transfer.Request != 0 {
|
||||
t.Fatal("zero-value transfer unexpectedly initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUSBNumber(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "busnum")
|
||||
if err := os.WriteFile(path, []byte("12\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := readUSBNumber(path); err != nil || got != 12 {
|
||||
t.Fatalf("readUSBNumber() = %d, %v, want 12, nil", got, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readUSBNumber(path); err == nil {
|
||||
t.Fatal("readUSBNumber(0) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "missing")
|
||||
if err := writeSysfs(path, "value"); err == nil {
|
||||
t.Fatal("writeSysfs(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("missing sysfs path was created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairDJIQMIRequiresQMICLIBeforeUSBAccess(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
_, err := repairDJIQMI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("repairDJIQMI() unexpectedly succeeded without qmicli")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "qmicli is required") || !strings.Contains(err.Error(), "libqmi-utils") {
|
||||
t.Fatalf("repairDJIQMI() error = %q, want an actionable qmicli prerequisite error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "DTR repair attempt") || strings.Contains(err.Error(), "USB topology") {
|
||||
t.Fatalf("repairDJIQMI() touched the repair path before checking qmicli: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDJISerialInterfaceLayout(t *testing.T) {
|
||||
if djiFirstSerialIndex != 0 || djiLastSerialIndex != 3 || djiATIndex != 2 || djiQMIIndex != 4 {
|
||||
t.Fatalf(
|
||||
"DJI interface layout = serial %d-%d, AT %d, QMI %d; want serial 0-3, AT 2, QMI 4",
|
||||
djiFirstSerialIndex,
|
||||
djiLastSerialIndex,
|
||||
djiATIndex,
|
||||
djiQMIIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindDJISerialInterfacesAlreadyCorrect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||
optionRoot := filepath.Join(driversRoot, "option")
|
||||
if err := os.MkdirAll(optionRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||
interfacePath := filepath.Join(usbRoot, fmt.Sprintf("1-1:1.%d", index))
|
||||
if err := os.MkdirAll(filepath.Join(interfacePath, fmt.Sprintf("ttyUSB%d", index)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(optionRoot, filepath.Join(interfacePath, "driver")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
interfaces, devices, atDevice, err := bindDJISerialInterfaces(
|
||||
context.Background(),
|
||||
sysRoot,
|
||||
devRoot,
|
||||
usbRoot,
|
||||
driversRoot,
|
||||
"1-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bindDJISerialInterfaces() error = %v", err)
|
||||
}
|
||||
if len(interfaces) != 4 || interfaces[2] != "1-1:1.2" {
|
||||
t.Fatalf("interfaces = %#v, want four interfaces with AT at 1-1:1.2", interfaces)
|
||||
}
|
||||
if len(devices) != 4 || devices[2] != filepath.Join(devRoot, "ttyUSB2") {
|
||||
t.Fatalf("devices = %#v, want four devices with AT at ttyUSB2", devices)
|
||||
}
|
||||
if atDevice != filepath.Join(devRoot, "ttyUSB2") {
|
||||
t.Fatalf("AT device = %q, want %q", atDevice, filepath.Join(devRoot, "ttyUSB2"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) {
|
||||
attempts := 0
|
||||
result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return djiQMIRepairResult{}, errors.New("transient QMI timeout")
|
||||
}
|
||||
return djiQMIRepairResult{ControlDevice: "/dev/cdc-wdm0"}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retryDJIQMI() error = %v", err)
|
||||
}
|
||||
if attempts != 3 || result.Attempts != 3 {
|
||||
t.Fatalf("attempts = %d, result.Attempts = %d, want 3", attempts, result.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDJIQMIStopsAfterBoundedAttempts(t *testing.T) {
|
||||
attempts := 0
|
||||
_, err := retryDJIQMI(context.Background(), 2, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
||||
attempts++
|
||||
return djiQMIRepairResult{}, errors.New("persistent failure")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("retryDJIQMI() unexpectedly succeeded")
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func repairDJIQMI(context.Context) (djiQMIRepairResult, error) {
|
||||
return djiQMIRepairResult{}, errors.New("DJI QMI repair is supported only on Linux")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDoctorHelpIsSuccessful(t *testing.T) {
|
||||
if err := runDoctor([]string{"--help"}); err != nil {
|
||||
t.Fatalf("runDoctor(--help) error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,29 @@ func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
|
||||
t.Fatal("installer replaces the current binary before validating database compatibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerProvidesRequiredQMIUtilities(t *testing.T) {
|
||||
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(scriptBytes)
|
||||
for _, required := range []string{
|
||||
"install_qmi_support()",
|
||||
"command -v qmicli",
|
||||
"command -v qmi-network",
|
||||
"apt-get install -y libqmi-utils",
|
||||
"dnf install -y libqmi-utils",
|
||||
"pacman -Sy --noconfirm libqmi",
|
||||
"apk add --no-cache qmi-utils",
|
||||
"Could not install or find qmicli/qmi-network",
|
||||
} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Errorf("installer is missing required QMI handling %q", required)
|
||||
}
|
||||
}
|
||||
mainStart := strings.LastIndex(script, "# --- Main ")
|
||||
if mainStart < 0 || !strings.Contains(script[mainStart:], "install_qmi_support") {
|
||||
t.Error("installer does not install QMI utilities from its main path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,16 @@ func main() {
|
||||
logger.Error("update failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "doctor":
|
||||
if err := runDoctor(rest); err != nil {
|
||||
logger.Error("doctor failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "carrier":
|
||||
if err := runCarrier(rest, os.Stdout); err != nil {
|
||||
logger.Error("carrier command failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "menu":
|
||||
if err := runMenu(logger); err != nil {
|
||||
logger.Error("menu failed", "error", err)
|
||||
@@ -120,6 +130,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
carrierProfileDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||
if err := vowifi.LoadCarrierProfileDirectory(carrierProfileDir); err != nil {
|
||||
return fmt.Errorf("load installed carrier profiles: %w", err)
|
||||
}
|
||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -745,6 +759,7 @@ func newVoWiFiOrchestrator(
|
||||
"service_center_timestamp": message.ServiceCenterTimestamp,
|
||||
"raw_rpdu": message.RawRPDU,
|
||||
"raw_tpdu": message.RawTPDU,
|
||||
"decode_error": message.DecodeError,
|
||||
})
|
||||
partsTotal := 1
|
||||
if message.Concat != nil && message.Concat.Total > 0 {
|
||||
@@ -809,6 +824,31 @@ func newVoWiFiOrchestrator(
|
||||
// acknowledged, otherwise the SMSC will keep retransmitting it.
|
||||
return nil
|
||||
},
|
||||
OnUSSD: func(ctx context.Context, message ims.ReceivedUSSD) error {
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"transport": "ims-ussd",
|
||||
"dcs": message.DCS,
|
||||
"call_id": message.CallID,
|
||||
"received_at": message.Timestamp,
|
||||
"raw_body": message.RawBody,
|
||||
})
|
||||
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: message.MessageID,
|
||||
DeviceID: message.DeviceID,
|
||||
ModemIMEI: deviceConfig.ModemIMEI,
|
||||
IMSI: message.IMSI,
|
||||
Peer: message.From,
|
||||
Direction: "inbound",
|
||||
Body: message.Text,
|
||||
Timestamp: message.Timestamp,
|
||||
Status: "received",
|
||||
Source: "ims-ussd",
|
||||
PartsTotal: 1,
|
||||
Read: false,
|
||||
Extra: extra,
|
||||
})
|
||||
return saveErr
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IMS provider: %w", deviceConfig.ID, err)
|
||||
|
||||
@@ -53,6 +53,9 @@ services:
|
||||
|
||||
volumes:
|
||||
# SQLite database + persistent state.
|
||||
# Note for NAS (e.g. QNAP Container Station / Synology): replace named volume
|
||||
# with a host absolute path (e.g. /share/Container/vocat/data:/opt/vocat/data)
|
||||
# to avoid volume isolation issues between bootstrap-admin and runtime.
|
||||
- vocat-data:/opt/vocat/data
|
||||
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
|
||||
# devices added after the container starts.
|
||||
|
||||
@@ -185,6 +185,11 @@ Quectel USB المدعومة (معرّف الشركة المصنعة USB `2c7c`)
|
||||
|
||||
تُنشر صورة GHCR لـ `linux/amd64` و`linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **ملاحظة حول النشر على NAS / QNAP Container Station**:
|
||||
> في أنظمة NAS مثل QNAP QTS / QuTS hero (Container Station)، قد تؤدي حسابات المشرفين المخصصة وآليات عزل وحدات التخزين إلى توجيه وحدات تخزين Docker المسماة (مثل `-v vocat-data:/opt/vocat/data`) إلى مسارات معزولة مختلفة بين أمر التهيئة `bootstrap-admin` وحاوية الخدمة الرئيسية، مما يتسبب في ظهور خطأ في كلمة المرور عند تسجيل الدخول عبر الويب.
|
||||
> بالنسبة لبيئات NAS، يوصى بشدة باستبدال وحدات التخزين المسماة بربط مسار مطلق على المضيف (مثل `-v /share/Container/vocat/data:/opt/vocat/data` على QNAP) لكل من التهيئة والتشغيل لضمان استمرارية متسقة لقاعدة بيانات SQLite.
|
||||
|
||||
## الإعدادات
|
||||
|
||||
يقرأ Vocat ملف إعدادات JSON اختياريًا من `VOCAT_CONFIG`، ثم يطبق متغيرات البيئة `VOCAT_*`. متغيرات البيئة لها الأولوية.
|
||||
|
||||
@@ -187,6 +187,11 @@ dispositivos o de conexión en caliente.
|
||||
|
||||
La imagen GHCR se publica para `linux/amd64` y `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **Nota sobre el despliegue en NAS / QNAP Container Station**:
|
||||
> En sistemas NAS como QNAP QTS / QuTS hero (Container Station), las cuentas de administrador personalizadas y el aislamiento de volúmenes pueden hacer que los volúmenes con nombre de Docker (ej. `-v vocat-data:/opt/vocat/data`) se resuelvan en rutas aisladas distintas entre la inicialización `bootstrap-admin` y el contenedor del servicio principal, provocando errores de contraseña incorrecta al iniciar sesión en la interfaz web.
|
||||
> En entornos NAS, se recomienda encarecidamente sustituir los volúmenes con nombre por un montaje bind con ruta absoluta del host (ej. `-v /share/Container/vocat/data:/opt/vocat/data` en QNAP) tanto para la inicialización como para la ejecución, garantizando la persistencia coherente de la base de datos SQLite.
|
||||
|
||||
## Configuración
|
||||
|
||||
Vocat lee un archivo de configuración JSON opcional desde `VOCAT_CONFIG` y luego aplica las variables de entorno `VOCAT_*`. Las variables de entorno tienen prioridad.
|
||||
|
||||
@@ -187,6 +187,11 @@ pas une découverte multi-périphériques ou à chaud complète.
|
||||
|
||||
L'image GHCR est publiée pour `linux/amd64` et `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **Note de déploiement NAS / QNAP Container Station** :
|
||||
> Sur les systèmes NAS tels que QNAP QTS / QuTS hero (Container Station), les comptes administrateurs personnalisés et les mécanismes d'isolation de volumes peuvent faire en sorte que les volumes nommés Docker (ex. `-v vocat-data:/opt/vocat/data`) soient résolus vers des chemins isolés différents entre l'initialisation unique `bootstrap-admin` et le conteneur de service principal, entraînant des erreurs de mot de passe incorrect sur l'interface Web.
|
||||
> Pour les environnements NAS, il est fortement recommandé de remplacer les volumes nommés par un montage bind avec chemin absolu de l'hôte (ex. `-v /share/Container/vocat/data:/opt/vocat/data` sur QNAP) pour l'initialisation et l'exécution afin de garantir une persistance cohérente de la base de données SQLite.
|
||||
|
||||
## Configuration
|
||||
|
||||
Vocat lit un fichier de configuration JSON optionnel depuis `VOCAT_CONFIG`, puis applique les variables d'environnement `VOCAT_*`. Les variables d'environnement ont la priorité.
|
||||
|
||||
@@ -169,6 +169,11 @@ docker run -d \
|
||||
|
||||
GHCR イメージは `linux/amd64` と `linux/arm64` 向けに公開されています。
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / QNAP Container Station デプロイ時の注意点**:
|
||||
> QNAP QTS / QuTS hero (Container Station) などの NAS 環境では、非 root カスタム管理者権限とボリューム分離メカニズムにより、Docker の名前付きボリューム(例: `-v vocat-data:/opt/vocat/data`)を使用すると、初回の `bootstrap-admin` 初期化時とデーモン起動時で異なる隔離パスに書き込まれ、Web ログイン時にパスワードエラーとなる場合があります。
|
||||
> NAS 環境では、初期化と常駐コンテナの両方で名前付きボリュームの代わりにホストの絶対パスバインドマウント(例: QNAP の `-v /share/Container/vocat/data:/opt/vocat/data`)を使用することを推奨します。
|
||||
|
||||
## 設定
|
||||
|
||||
Vocat は `VOCAT_CONFIG` からオプションの JSON 設定ファイルを読み込み、次に `VOCAT_*` 環境変数を適用します。環境変数が優先されます。
|
||||
|
||||
@@ -186,6 +186,11 @@ TUN, настройки сети и устройств, добавленных
|
||||
|
||||
Образ GHCR публикуется для `linux/amd64` и `linux/arm64`.
|
||||
|
||||
> [!TIP]
|
||||
> **Примечание по развертыванию на NAS / QNAP Container Station**:
|
||||
> В системах NAS, таких как QNAP QTS / QuTS hero (Container Station), из-за нестандартных прав администратора и механизмов изоляции томов именованные тома Docker (например, `-v vocat-data:/opt/vocat/data`) могут разрешаться в разные изолированные пути между выполнением команды `bootstrap-admin` и основным контейнером службы, что приводит к ошибкам неверного пароля при входе через веб-интерфейс.
|
||||
> Для сред NAS настоятельно рекомендуется использовать монтирование с абсолютным путем хоста (например, `-v /share/Container/vocat/data:/opt/vocat/data` на QNAP) как для инициализации, так и для запуска службы, чтобы гарантировать согласованность базы данных SQLite.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Vocat читает необязательный JSON-файл конфигурации из `VOCAT_CONFIG`, затем применяет переменные окружения `VOCAT_*`. Переменные окружения имеют приоритет.
|
||||
|
||||
@@ -92,6 +92,13 @@ sudo bash install.sh 0.0.2
|
||||
|
||||
VoWiFi IMS 必须使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安装脚本会从当前固件自己的软件源尝试安装严格匹配的 `ip-full`、`kmod-ipsec`、`kmod-ipsec4/6`、`kmod-crypto-authenc`、AES-CBC 和 SHA1 组件。若软件源没有与当前内核匹配的模块,必须更换包含这些组件的固件,禁止强装其他内核版本的 kmod。
|
||||
|
||||
如果你的内核确实无法提供 XFRM/IPsec,且仅需要非 VoWiFi 功能(蜂窝短信、数据等),可在安装时加上 `--skip-vowifi-check`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh --skip-vowifi-check
|
||||
```
|
||||
|
||||
安装程序会:
|
||||
|
||||
- 检测 `amd64`、`386`、`arm64` 或 `armv7` 架构;
|
||||
@@ -168,6 +175,11 @@ docker run -d \
|
||||
|
||||
GHCR 镜像发布为 `linux/amd64` 与 `linux/arm64`。
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / 威联通 (QNAP Container Station) 部署说明**:
|
||||
> 在威联通等 NAS 系统的 Container Station 下部署时,由于系统的非 Root 自定义管理员权限与卷隔离机制,使用 Docker 命名卷(如 `-v vocat-data:/opt/vocat/data`)在执行一次性初始化 `bootstrap-admin` 和启动常驻服务时,两者的卷极易被解析至不同的隔离路径,导致 Web 端登录时提示密码错误。
|
||||
> 建议在 NAS 环境下部署时,将 `-v vocat-data:/opt/vocat/data` 替换为宿主机的绝对路径挂载(例如威联通上的 `-v /share/Container/vocat/data:/opt/vocat/data`),以确保初始化与运行期读写同一个 SQLite 数据库文件。
|
||||
|
||||
### USB SIM 读卡器
|
||||
|
||||
USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支持的软件包管理器上
|
||||
@@ -175,6 +187,16 @@ USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支
|
||||
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
|
||||
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。
|
||||
|
||||
### QMI 命令行工具
|
||||
|
||||
VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理
|
||||
分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时,
|
||||
Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用
|
||||
`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`。
|
||||
|
||||
`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
|
||||
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
||||
|
||||
## 配置
|
||||
|
||||
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
||||
@@ -195,6 +217,25 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
||||
|
||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||
|
||||
## Apple IPCC 运营商规则导入
|
||||
|
||||
VoCat 可以离线解析用户提供的 `.ipcc`,将 Apple 的 XML/二进制 plist
|
||||
转换为可审查的运营商 Profile。默认只预览,不会修改配置:
|
||||
|
||||
```bash
|
||||
vocat carrier import-ipcc Carrier_iPhone.ipcc
|
||||
```
|
||||
|
||||
确认警告和匹配范围后,使用 `--install` 安装;重启 VoCat 后生效:
|
||||
|
||||
```bash
|
||||
vocat carrier import-ipcc --install Carrier_iPhone.ipcc
|
||||
```
|
||||
|
||||
导入器不会复制关闭证书验证、绕过运营商授权、APN 凭据、紧急呼叫或
|
||||
设备型号专属媒体参数。完整字段和冲突处理说明见
|
||||
[CARRIER_IPCC_IMPORT.md](CARRIER_IPCC_IMPORT.md)。
|
||||
|
||||
## Telegram 机器人
|
||||
|
||||
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
||||
|
||||
@@ -169,6 +169,11 @@ docker run -d \
|
||||
|
||||
GHCR 映像發佈為 `linux/amd64` 與 `linux/arm64`。
|
||||
|
||||
> [!TIP]
|
||||
> **NAS / 威聯通 (QNAP Container Station) 部署說明**:
|
||||
> 在威聯通等 NAS 系統的 Container Station 下部署時,由於系統的非 Root 自訂管理員權限與磁碟區隔離機制,使用 Docker 具名磁碟區(如 `-v vocat-data:/opt/vocat/data`)在執行一次性初始化 `bootstrap-admin` 與啟動常駐服務時,兩者的磁碟區極易被解析至不同的隔離路徑,導致 Web 端登入時提示密碼錯誤。
|
||||
> 建議在 NAS 環境下部署時,將 `-v vocat-data:/opt/vocat/data` 替換為宿主機的絕對路徑掛載(例如威聯通上的 `-v /share/Container/vocat/data:/opt/vocat/data`),以確保初始化與執行期讀寫同一個 SQLite 資料庫檔案。
|
||||
|
||||
## 配置
|
||||
|
||||
Vocat 先從 `VOCAT_CONFIG` 讀取可選的 JSON 配置檔,再套用 `VOCAT_*` 環境變數。環境變數優先級更高。
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
# 企业微信消息推送实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
|
||||
|
||||
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
|
||||
|
||||
**技术栈:** Go 1.25、标准库 `net/http` 与 `encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
|
||||
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
|
||||
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
|
||||
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
|
||||
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
|
||||
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
|
||||
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
|
||||
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
|
||||
- 修改:`web/src/types.ts`,扩展通知设置类型。
|
||||
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
|
||||
|
||||
### 任务 1:企业微信模板与响应判定
|
||||
|
||||
**文件:**
|
||||
- 创建:`internal/server/wecom_notification_test.go`
|
||||
- 创建:`internal/server/wecom_notification.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的模板与响应测试**
|
||||
|
||||
```go
|
||||
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderWecomPayload(
|
||||
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
|
||||
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
|
||||
)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
|
||||
t.Fatalf("payload = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
|
||||
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
|
||||
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
|
||||
t.Fatalf("template %q was accepted", template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
|
||||
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:FAIL,提示 `renderWecomPayload`、`wecomTemplateValues` 和 `validateWecomResponse` 未定义。
|
||||
|
||||
- [ ] **步骤 3:实现最少的模板与响应代码**
|
||||
|
||||
在 `internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{` 或 `}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
|
||||
|
||||
```go
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
for _, name := range wecomTemplateVariableNames {
|
||||
encoded, _ := json.Marshal(values[name])
|
||||
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
|
||||
}
|
||||
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
|
||||
return nil, errors.New("wecom.payload_template contains an unsupported variable")
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct { ErrCode int `json:"errcode"` }
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
|
||||
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomTestValues(now time.Time) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
|
||||
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
|
||||
if err != nil { return err }
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil { return err }
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
if err != nil { return err }
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if err != nil { return fmt.Errorf("create WeCom notification request: %w", err) }
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
|
||||
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
|
||||
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:PASS。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
|
||||
|
||||
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
|
||||
|
||||
### 任务 2:设置 API 与敏感 Webhook URL
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的 API 测试**
|
||||
|
||||
```go
|
||||
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
|
||||
}
|
||||
|
||||
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
|
||||
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
|
||||
|
||||
预期:FAIL,设置 API 返回 `invalid_notification_channel`。
|
||||
|
||||
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
|
||||
|
||||
在 `notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings` 和 `payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig`、`handleNotificationTest` 和发送分支中支持 `wecom`。
|
||||
|
||||
```go
|
||||
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
|
||||
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
```
|
||||
|
||||
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
|
||||
|
||||
```go
|
||||
case "wecom_template":
|
||||
var template string
|
||||
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
|
||||
return fmt.Errorf("%s must be a template string", field)
|
||||
}
|
||||
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
|
||||
return err
|
||||
|
||||
case "wecom":
|
||||
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
|
||||
return errors.New("wecom.urls and wecom.payload_template are required")
|
||||
}
|
||||
```
|
||||
|
||||
测试消息的变量必须为 `event: "test"`、`title: "vocat"`、`message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
|
||||
|
||||
预期:PASS,GET/PUT 响应不会泄露 `key`,但数据库保留原 URL。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/settings_api.go internal/server/settings_api_test.go internal/store/settings.go && git commit -m "feat: configure WeCom notifications"`
|
||||
|
||||
预期:创建设置 API 与敏感配置提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 3:接入短信与自动任务分发
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的事件变量测试**
|
||||
|
||||
```go
|
||||
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
|
||||
message := smsNotification{DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", Number: "+447386", Time: time.Unix(1700000000, 0), Content: "hello"}
|
||||
values := wecomSMSValues(message)
|
||||
if values["event"] != "sms.received" || values["content"] != "hello" || values["device_label"] != "EC20" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
values := wecomAutomaticTaskValues(automaticTaskNotification{Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1700000000, 0)})
|
||||
if values["event"] != "automatic_task.completed" || values["message"] != "任务已完成" || values["number"] != "" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues' -count=1`
|
||||
|
||||
预期:FAIL,两个事件变量构建函数未定义。
|
||||
|
||||
- [ ] **步骤 3:实现分发接入**
|
||||
|
||||
在企业微信模块中实现 `wecomSMSValues` 和 `wecomAutomaticTaskValues`,填充全部已声明变量,短信专属字段在自动任务事件中设为空字符串。然后将 `wecom` 加入以下分发列表与 switch:
|
||||
|
||||
```go
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
```
|
||||
|
||||
```go
|
||||
channels := []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"}
|
||||
for _, channel := range channels {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { continue }
|
||||
if err != nil { s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err); continue }
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(setting.Config, &config); err != nil { s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err); continue }
|
||||
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil { s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err) }
|
||||
}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
```
|
||||
|
||||
保持既有游标、错误限流日志和其他通道的行为不变。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues|TestValidateSMSNotificationConfig' -count=1`
|
||||
|
||||
预期:PASS,`validateSMSNotificationConfig` 也接受包含有效 URL 和模板的 `wecom` 配置。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go && git commit -m "feat: dispatch WeCom notifications"`
|
||||
|
||||
预期:创建两类事件分发接入提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 4:企业微信配置界面
|
||||
|
||||
**文件:**
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:扩展前端类型和表单映射**
|
||||
|
||||
在 `NotificationSettings` 与 `NotifyForms` 中增加 `wecom`。新增以下表单类型和默认请求体;URL 数组保持一项一个输入行的既有 `UrlListEditor` 约定。
|
||||
|
||||
```ts
|
||||
export interface WecomForm {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
payloadTemplate: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||
"msgtype": "text",
|
||||
"text": { "content": {{message}} }
|
||||
}`;
|
||||
```
|
||||
|
||||
`formsFromNotifications` 读取 `payload_template`,`buildNotificationsPayload` 输出 `payload_template`,测试请求则修剪并移除空 URL。
|
||||
|
||||
- [ ] **步骤 2:实现企业微信页签与测试请求**
|
||||
|
||||
在 `PushTabs.tsx` 增加 `WecomTab`,显示启用开关、`UrlListEditor`、JSON `Textarea` 和变量说明。URL 列表文案必须明确“每个 Webhook URL 单独一行,点击添加 URL 增加”,不得提示使用分隔符。
|
||||
|
||||
```tsx
|
||||
<Field label={t("JSON 请求体模板")} hint={<span>变量必须作为 JSON 值使用,例如 <code>{'{{message}}'}</code>。</span>}>
|
||||
<Textarea value={value.payloadTemplate} onChange={(event) => onChange({ payloadTemplate: event.target.value })} disabled={off} rows={12} />
|
||||
</Field>
|
||||
```
|
||||
|
||||
在 `SettingsPage.tsx` 增加 `testingWecom`、`onTestWecom`、企业微信页签与组件渲染。测试请求使用 `POST /settings/notifications/wecom/test` 和企业微信表单 payload;成功与失败消息沿用现有通知测试模式。
|
||||
|
||||
- [ ] **步骤 3:运行前端构建验证**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:Vite 类型检查与生产构建均以退出码 0 完成。
|
||||
|
||||
- [ ] **步骤 4:提交本任务**
|
||||
|
||||
运行:`git add web/src/types.ts web/src/components/settings/model.ts web/src/components/settings/PushTabs.tsx web/src/pages/SettingsPage.tsx && git commit -m "feat: add WeCom notification settings"`
|
||||
|
||||
预期:创建企业微信设置 UI 提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 5:完整验证
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/wecom_notification.go`
|
||||
- 修改:`internal/server/wecom_notification_test.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:格式化 Go 代码**
|
||||
|
||||
运行:`gofmt -w internal/server/wecom_notification.go internal/server/wecom_notification_test.go internal/server/settings_api.go internal/server/settings_api_test.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go internal/store/settings.go`
|
||||
|
||||
预期:所有修改的 Go 文件采用项目标准格式。
|
||||
|
||||
- [ ] **步骤 2:运行前端生产构建**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。
|
||||
|
||||
- [ ] **步骤 3:运行后端回归测试**
|
||||
|
||||
运行:`go test ./...`
|
||||
|
||||
预期:所有目标包通过,无失败测试;`cmd/vocat` 和 `web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。
|
||||
|
||||
- [ ] **步骤 4:检查最终变更**
|
||||
|
||||
运行:`git diff --check && git status --short`
|
||||
|
||||
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
|
||||
@@ -1,55 +0,0 @@
|
||||
# 企业微信消息推送设计
|
||||
|
||||
## 目标
|
||||
|
||||
新增独立的 `wecom` 通知通道,通过企业微信“消息推送(原群机器人)”Webhook 推送新收到的短信和自动任务执行结果。外部 API 契约与既有通知通道保持一致。
|
||||
|
||||
## 配置模型
|
||||
|
||||
`wecom` 配置包含:
|
||||
|
||||
- `enabled`:是否启用通道。
|
||||
- `urls`:一个或多个企业微信消息推送 Webhook URL。Web 设置页将每个 URL
|
||||
显示为独立输入行,通过“添加 URL”按钮新增输入行、通过删除按钮移除输入行;
|
||||
不使用逗号、空格或换行分隔多个 URL。
|
||||
- `payload_template`:完整 JSON 请求体模板。
|
||||
|
||||
Webhook URL 含有企业微信访问密钥,必须作为敏感配置存储、在读取接口中脱敏,并在日志和错误信息中避免泄露。URL 沿用现有出站 URL 校验与 SSRF 防护。
|
||||
|
||||
## 模板语义
|
||||
|
||||
用户在 Web 设置页编辑完整 JSON 请求体,以选择企业微信支持的任意消息格式,例如 `text`、`markdown`、`news` 或 `template_card`。
|
||||
|
||||
模板变量仅能作为 JSON 值出现,服务端使用 JSON 编码后的字符串替换,调用方不得在变量外添加引号。示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": {{message}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可用变量:
|
||||
|
||||
- 通用:`{{event}}`、`{{title}}`、`{{message}}`、`{{timestamp}}`。
|
||||
- 短信事件:`{{content}}`、`{{number}}`、`{{device_id}}`、`{{device_name}}`、`{{device_label}}`、`{{time}}`。
|
||||
|
||||
自动任务使用通用变量;短信专属变量在自动任务中替换为空字符串。模板渲染后必须为非空 JSON 对象,不得保留模板变量;无效模板在保存和测试时拒绝。
|
||||
|
||||
## 发送流程
|
||||
|
||||
短信分发器为 `wecom` 维护独立游标,发送失败不会阻塞其他通知渠道。自动任务完成后,和 Telegram、Bark、邮件、PushPlus、通用 Webhook 一样,向已启用的 `wecom` 通道发送结果。
|
||||
|
||||
发送器逐一 POST 渲染后的 JSON 到所有配置 URL,使用现有受限 HTTP 客户端。除 HTTP 2xx 外,企业微信返回 JSON 的 `errcode` 非零也视为服务商拒绝。
|
||||
|
||||
## Web 与 API
|
||||
|
||||
设置 API 将 `wecom` 加入已知通道和配置字段白名单,并提供 `POST /api/settings/notifications/wecom/test`。Web 设置页新增“企业微信”页签、启用开关、逐行编辑的 Webhook URL 列表、JSON 模板编辑器和测试按钮。
|
||||
|
||||
默认模板使用 `text` 消息,发送一条可辨识的测试内容。
|
||||
|
||||
## 验证
|
||||
|
||||
后端测试覆盖:配置字段验证、模板的 JSON 转义和拒绝无效模板、企业微信请求载荷、非零 `errcode` 失败处理、通知设置 API 读写与敏感 Webhook URL 保留。前端构建用于验证新增表单与类型契约。
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.43.0
|
||||
howett.net/plist v1.0.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -49,10 +50,13 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICSTCCAe+gAwIBAgIQbmhWeneg7nyF7hg5Y9+qejAKBggqhkjOPQQDAjBEMRgw
|
||||
FgYDVQQKEw9HU00gQXNzb2NpYXRpb24xKDAmBgNVBAMTH0dTTSBBc3NvY2lhdGlv
|
||||
biAtIFJTUDIgUm9vdCBDSTEwIBcNMTcwMjIyMDAwMDAwWhgPMjA1MjAyMjEyMzU5
|
||||
NTlaMEQxGDAWBgNVBAoTD0dTTSBBc3NvY2lhdGlvbjEoMCYGA1UEAxMfR1NNIEFz
|
||||
c29jaWF0aW9uIC0gUlNQMiBSb290IENJMTBZMBMGByqGSM49AgEGCCqGSM49AwEH
|
||||
A0IABJ1qutL0HCMX52GJ6/jeibsAqZfULWj/X10p/Min6seZN+hf5llovbCNuB2n
|
||||
unLz+O8UD0SUCBUVo8e6n9X1TuajgcAwgb0wDgYDVR0PAQH/BAQDAgEGMA8GA1Ud
|
||||
EwEB/wQFMAMBAf8wEwYDVR0RBAwwCogIKwYBBAGC6WAwFwYDVR0gAQH/BA0wCzAJ
|
||||
BgdngRIBAgEAME0GA1UdHwRGMEQwQqBAoD6GPGh0dHA6Ly9nc21hLWNybC5zeW1h
|
||||
dXRoLmNvbS9vZmZsaW5lY2EvZ3NtYS1yc3AyLXJvb3QtY2kxLmNybDAdBgNVHQ4E
|
||||
FgQUgTcPUSXQsdQI1MOyMubSXnlb6/swCgYIKoZIzj0EAwIDSAAwRQIgIJdYsOMF
|
||||
WziPK7l8nh5mu0qiRiVf25oa9ullG/OIASwCIQDqCmDrYf+GziHXBOiwJwnBaeBO
|
||||
aFsiLzIEOaUuZwdNUw==
|
||||
-----END CERTIFICATE-----
|
||||
+32
-1
@@ -3,6 +3,7 @@ package device
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -14,9 +15,24 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
// GSM Association RSP2 Root CI1; SHA-256 fingerprint:
|
||||
// 5E:3E:91:FD:45:43:27:C3:AF:5D:32:A7:A7:3B:BC:59:FE:43:AA:7D:85:FD:32:D5:DB:44:42:3F:80:A5:6B:B3.
|
||||
//
|
||||
//go:embed certs/gsma-rsp2-root-ci1.pem
|
||||
var gsmaRSP2RootCI1PEM []byte
|
||||
|
||||
var gsmaRSP2RootCI1SHA256 = [32]byte{
|
||||
0x5e, 0x3e, 0x91, 0xfd, 0x45, 0x43, 0x27, 0xc3,
|
||||
0xaf, 0x5d, 0x32, 0xa7, 0xa7, 0x3b, 0xbc, 0x59,
|
||||
0xfe, 0x43, 0xaa, 0x7d, 0x85, 0xfd, 0x32, 0xd5,
|
||||
0xdb, 0x44, 0x42, 0x3f, 0x80, 0xa5, 0x6b, 0xb3,
|
||||
}
|
||||
|
||||
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
|
||||
// network half of the LPA download flow: the host authenticates nothing itself
|
||||
// (the eUICC does all certificate verification on-card); it only shuttles the
|
||||
@@ -50,13 +66,28 @@ func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
|
||||
}
|
||||
roots, err := es9pRootCAs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: validated.Host,
|
||||
endpoint: validated,
|
||||
http: netguard.NewPublicHTTPClient(90*time.Second, true),
|
||||
http: netguard.NewPublicHTTPClientWithRootCAs(90*time.Second, true, roots),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func es9pRootCAs() (*x509.CertPool, error) {
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(gsmaRSP2RootCI1PEM) {
|
||||
return nil, errors.New("esim: load GSMA RSP2 Root CI1 certificate")
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
|
||||
// own explanation (surfaced verbatim, as the reference implementation does).
|
||||
type es9pError struct {
|
||||
|
||||
@@ -3,8 +3,11 @@ package device
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -12,6 +15,30 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestES9PRootCAsIncludeGSMARSP2RootCI1(t *testing.T) {
|
||||
roots, err := es9pRootCAs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, _ := pem.Decode(gsmaRSP2RootCI1PEM)
|
||||
if block == nil {
|
||||
t.Fatal("GSMA Root CI1 PEM did not decode")
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual := sha256.Sum256(certificate.Raw); actual != gsmaRSP2RootCI1SHA256 {
|
||||
t.Fatalf("GSMA root SHA-256 = %X, want %X", actual, gsmaRSP2RootCI1SHA256)
|
||||
}
|
||||
if certificate.Subject.CommonName != "GSM Association - RSP2 Root CI1" || !certificate.IsCA {
|
||||
t.Fatalf("unexpected GSMA root certificate: subject=%q ca=%v", certificate.Subject.CommonName, certificate.IsCA)
|
||||
}
|
||||
if _, err := certificate.Verify(x509.VerifyOptions{Roots: roots}); err != nil {
|
||||
t.Fatalf("GSMA root is not trusted by the ES9+ pool: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestES9P routes an es9pClient at a throwaway TLS server.
|
||||
func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
|
||||
t.Helper()
|
||||
|
||||
@@ -121,3 +121,22 @@ func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForSIMRecognizesGiffgaffWithoutRelabelingGenericO2(t *testing.T) {
|
||||
for _, identity := range []CarrierIdentity{
|
||||
{IMSI: "234100000000001", GID1: "508FFFFF", MNCLength: 2},
|
||||
{IMSI: "234100000000001", SPN: "GiffGaff", MNCLength: 2},
|
||||
} {
|
||||
plmn, name, country, ok := CarrierForSIM(identity)
|
||||
if !ok || plmn != "23410" || name != "giffgaff" || country != "GB" {
|
||||
t.Fatalf("giffgaff identity = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
_, name, _, ok := CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "234100000000001", MNCLength: 2,
|
||||
})
|
||||
if !ok || name == "giffgaff" {
|
||||
t.Fatalf("generic O2 SIM was mislabeled as giffgaff: (%q, %v)", name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +548,14 @@ func decodeGSM7(septets []byte) (string, error) {
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// DecodeGSM7Septets decodes a GSM 7-bit default-alphabet string whose septets
|
||||
// are stored one code per byte (the form USSI bodies use when DCS=0x0F). It
|
||||
// returns the decoded text and ok=false if a code is out of range.
|
||||
func DecodeGSM7Septets(data string) (string, bool) {
|
||||
decoded, err := decodeGSM7([]byte(data))
|
||||
return decoded, err == nil
|
||||
}
|
||||
|
||||
type pduCursor struct {
|
||||
data []byte
|
||||
index int
|
||||
@@ -758,13 +766,29 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
byteCount := (int(length) + 1) / 2
|
||||
var byteCount int
|
||||
var septetCount int
|
||||
if toa&0x70 == 0x50 {
|
||||
// 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field
|
||||
// indicates the number of useful semi-octets (i.e. characters * 7 / 4, rounded up).
|
||||
// The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2.
|
||||
// However, some non-standard sources specify length as the direct count of septets
|
||||
// (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes).
|
||||
if length >= 7 {
|
||||
byteCount = (int(length) + 1) / 2
|
||||
septetCount = int(length) * 4 / 7
|
||||
} else {
|
||||
byteCount = (int(length)*7 + 7) / 8
|
||||
septetCount = int(length)
|
||||
}
|
||||
} else {
|
||||
byteCount = (int(length) + 1) / 2
|
||||
}
|
||||
value, err := cursor.bytes(byteCount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if toa&0x70 == 0x50 {
|
||||
septetCount := int(length) * 4 / 7
|
||||
septets, unpackErr := unpackSeptets(value, septetCount, 0)
|
||||
if unpackErr != nil {
|
||||
return "", unpackErr
|
||||
@@ -852,7 +876,13 @@ func decodeUserData(
|
||||
message.Text = string(utf16.Decode(units))
|
||||
return nil
|
||||
default:
|
||||
// 8-bit (binary) user data has no portable text representation, so the
|
||||
// raw payload bytes are rendered as uppercase hexadecimal after the user
|
||||
// data header is stripped. This keeps the bubble non-empty and gives a
|
||||
// faithful rendering of the delivered content rather than a blank "".
|
||||
message.Encoding = SMSEncoding8BitPDU
|
||||
payload := data[headerBytes:]
|
||||
message.Text = strings.ToUpper(hex.EncodeToString(payload))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,3 +263,80 @@ func TestParseCMGLPreservesUndecodableRecord(t *testing.T) {
|
||||
t.Fatalf("messages = %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAlphanumericTPAddress(t *testing.T) {
|
||||
// "TEST" encoded as 4 GSM-7 septets packed into 4 bytes (non-standard septet count format: length=4).
|
||||
cursor := &pduCursor{data: []byte{0x04, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
|
||||
address, err := readTPAddress(cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("readTPAddress error = %v", err)
|
||||
}
|
||||
if address != "TEST" {
|
||||
t.Fatalf("readTPAddress = %q, want TEST", address)
|
||||
}
|
||||
if cursor.index != len(cursor.data) {
|
||||
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAlphanumericTPAddressStandard3GPP(t *testing.T) {
|
||||
// "Google" (6 chars) encoded per 3GPP TS 23.040 §9.1.2.5:
|
||||
// length = 0x0B (11 useful semi-octets), TOA = 0xD0 (Alphanumeric),
|
||||
// 6 bytes payload: C7 F7 FB CC 2E 03
|
||||
cursor := &pduCursor{data: []byte{0x0b, 0xd0, 0xc7, 0xf7, 0xfb, 0xcc, 0x2e, 0x03}}
|
||||
address, err := readTPAddress(cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("readTPAddress standard 3GPP error = %v", err)
|
||||
}
|
||||
if address != "Google" {
|
||||
t.Fatalf("readTPAddress standard 3GPP = %q, want Google", address)
|
||||
}
|
||||
if cursor.index != len(cursor.data) {
|
||||
t.Fatalf("cursor did not consume all bytes: %d/%d", cursor.index, len(cursor.data))
|
||||
}
|
||||
|
||||
// "TEST" (4 chars) with standard 3GPP semi-octets (length = 0x08, 8 semi-octets -> 4 bytes)
|
||||
cursorTest := &pduCursor{data: []byte{0x08, 0xd0, 0xd4, 0xe2, 0x94, 0x0a}}
|
||||
addressTest, err := readTPAddress(cursorTest)
|
||||
if err != nil {
|
||||
t.Fatalf("readTPAddress standard 3GPP TEST error = %v", err)
|
||||
}
|
||||
if addressTest != "TEST" {
|
||||
t.Fatalf("readTPAddress standard 3GPP TEST = %q, want TEST", addressTest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDeliverPDUWithAlphanumericSender(t *testing.T) {
|
||||
// SMS-DELIVER with alphanumeric originator "VoCat" and empty user data.
|
||||
// SMSC length=0, first octet=0x04, OA length=0x05, OA TON=0xD0,
|
||||
// OA bytes pack "VoCat" (5 septets -> 5 bytes), PID=0x00, DCS=0x00,
|
||||
// SCTS=7 bytes, UDL=0x00.
|
||||
message, err := decodeSMSPDU("000405D0D6F7304C0700004210203040500000")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSMSPDU error = %v", err)
|
||||
}
|
||||
if message.From != "VoCat" {
|
||||
t.Fatalf("From = %q, want VoCat", message.From)
|
||||
}
|
||||
if message.Direction != SMSDirectionReceived {
|
||||
t.Fatalf("Direction = %q", message.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
||||
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
|
||||
// Built from the GSM-7 deliver vector by swapping the DCS to 0xF5
|
||||
// and replacing the user data with three raw binary bytes.
|
||||
message, err := decodeSMSPDU(
|
||||
"000405912143F500F54210203040500003AABBCC",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("decode 8-bit: %v", err)
|
||||
}
|
||||
if message.Encoding != SMSEncoding8BitPDU ||
|
||||
message.Text != "AABBCC" ||
|
||||
message.RawUserData != "AABBCC" {
|
||||
t.Fatalf("8-bit message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -69,7 +69,12 @@ func (manager *Manager) readSnapshot(
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
|
||||
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
|
||||
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
|
||||
// timeout, and every refresh holds the device lock while it does so,
|
||||
// starving the AT terminal. Only fall back to QMI when the AT CPIN
|
||||
// probe already proved a card is present.
|
||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
||||
cancelQMI()
|
||||
@@ -186,14 +191,18 @@ func (manager *Manager) readSnapshot(
|
||||
snapshot.RegistrationSource = "COPS"
|
||||
}
|
||||
if snapshot.IMEI == "" {
|
||||
response, ok := optional("AT+CGSN")
|
||||
if ok {
|
||||
snapshot.IMEI = parseIdentifier(
|
||||
response,
|
||||
[]string{"+CGSN:", "+GSN:"},
|
||||
14,
|
||||
17,
|
||||
)
|
||||
// AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
|
||||
// returns the IMEI line but never a final OK, so it would block until the
|
||||
// caller's deadline (30s during a periodic refresh) and starve every other
|
||||
// device operation behind the lock. Give it an independent short timeout
|
||||
// and let the WWAN transport's drain discard the trailing stale bytes.
|
||||
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
|
||||
cancelCGSN()
|
||||
if cgsnErr == nil {
|
||||
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
|
||||
snapshot.IMEI = imei
|
||||
}
|
||||
}
|
||||
}
|
||||
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||
@@ -211,7 +220,6 @@ func (manager *Manager) readSnapshot(
|
||||
// Preserve a prior successful read across a transient QMI/AT failure.
|
||||
snapshot.IMEI = previousSnapshot.IMEI
|
||||
}
|
||||
|
||||
if response, ok := optional("AT+CFUN?"); ok {
|
||||
if mode, found := parseCFUN(response); found {
|
||||
snapshot.OperatingMode = mode
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
// lenientATClient answers every command with a bare CommandError and records
|
||||
// the commands it saw. It lets snapshot tests exercise the full readSnapshot
|
||||
// sequence without enumerating every step of the transcript.
|
||||
type lenientATClient struct {
|
||||
mu sync.Mutex
|
||||
commands []string
|
||||
cgsnDelay time.Duration
|
||||
cgsnIMEI string
|
||||
}
|
||||
|
||||
func (c *lenientATClient) Execute(ctx context.Context, command string) (modem.Response, error) {
|
||||
c.mu.Lock()
|
||||
c.commands = append(c.commands, command)
|
||||
c.mu.Unlock()
|
||||
if command == "ATI" {
|
||||
return okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410"), nil
|
||||
}
|
||||
if command == "AT+CGSN" && c.cgsnDelay > 0 {
|
||||
select {
|
||||
case <-time.After(c.cgsnDelay):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
if command == "AT+CGSN" && c.cgsnIMEI != "" {
|
||||
return okResponse("+CGSN: " + c.cgsnIMEI), nil
|
||||
}
|
||||
return modem.Response{}, &modem.CommandError{Command: command, Final: "ERROR"}
|
||||
}
|
||||
|
||||
func (c *lenientATClient) WaitURC(context.Context, func(string) bool) (string, error) {
|
||||
return "", errors.New("no URC")
|
||||
}
|
||||
|
||||
func (c *lenientATClient) Close() error { return nil }
|
||||
|
||||
func (c *lenientATClient) saw(command string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, seen := range c.commands {
|
||||
if seen == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AT+CGSN on some MHI modems returns the IMEI line but never a final OK, so it
|
||||
// would block until the caller's deadline and hold the device lock for the
|
||||
// whole periodic refresh. The snapshot must bound CGSN with its own short
|
||||
// timeout instead of inheriting the refresh deadline.
|
||||
func TestManagerRefreshBoundsCGSNTimeout(t *testing.T) {
|
||||
client := &lenientATClient{cgsnDelay: 5 * time.Second}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
snapshot, err := manager.Refresh(ctx, id)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
// CGSN times out after CommandTimeout (1s in the test manager); the rest
|
||||
// of the snapshot is immediate. An un-bounded CGSN would wait for the
|
||||
// 4s outer deadline (or worse, a real 30s refresh deadline).
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("Refresh took %s; CGSN was not bounded by CommandTimeout", elapsed)
|
||||
}
|
||||
if !client.saw("AT+CGSN") {
|
||||
t.Fatalf("CGSN was never sent; commands = %v", client.commands)
|
||||
}
|
||||
if snapshot.IMEI != "" {
|
||||
t.Fatalf("IMEI = %q, want empty after CGSN timeout", snapshot.IMEI)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing SIM must not fall back to the QMI UIM ICCID read: without a READY
|
||||
// card that call blocks until its long timeout and starves the AT terminal
|
||||
// behind the device lock.
|
||||
func TestManagerRefreshSkipsQMIICCIDWithoutReadySIM(t *testing.T) {
|
||||
// CGSN succeeds so the snapshot does not fall back to the QMI DMS IMEI
|
||||
// read either; the test focuses on the UIM ICCID fallback being skipped
|
||||
// without a READY card.
|
||||
client := &lenientATClient{cgsnIMEI: "866241014372802"}
|
||||
manager, err := NewManager(Options{
|
||||
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
|
||||
ID: "mhi-wwan0",
|
||||
Product: "PCIe/MHI WWAN modem",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
NetworkInterface: "wwan0",
|
||||
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
|
||||
}}},
|
||||
Opener: &staticOpener{client: client},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||
|
||||
qmiCalls := 0
|
||||
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||
qmiCalls++
|
||||
return nil, errors.New("QMI should not be opened without a SIM")
|
||||
}
|
||||
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
// Exactly one QMI open is expected: the immutable DMS IMEI read runs
|
||||
// unconditionally for native QMI candidates (IMEI is hardware identity,
|
||||
// independent of the card). The UIM ICCID fallback, which would block
|
||||
// without a READY SIM, must be skipped.
|
||||
if qmiCalls != 1 {
|
||||
t.Fatalf("qmiRadioOpener called %d times, want 1 (DMS IMEI only, UIM ICCID must be skipped without a READY SIM)", qmiCalls)
|
||||
}
|
||||
for _, warning := range snapshot.Warnings {
|
||||
if strings.Contains(warning, "QMI UIM") {
|
||||
t.Fatalf("unexpected QMI ICCID warning: %q", warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package i18n
|
||||
|
||||
// Keep feature-specific diagnostic strings together so additions to the proxy
|
||||
// probe do not cause conflicts in the shared dictionary.
|
||||
func init() {
|
||||
zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall."
|
||||
zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed."
|
||||
zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed."
|
||||
zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed."
|
||||
}
|
||||
+97
-15
@@ -12,9 +12,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
djiVendorID = "2ca3"
|
||||
dji4GProductID = "4006"
|
||||
// quectelVendorID covers Quectel USB modems exposed purely as serial or
|
||||
// RNDIS/ECM devices (for example the EC200A at 2c7c:6005). Their control
|
||||
// interface is not bound to qmi_wwan, so the QMI-binding gate would skip
|
||||
// them even though they expose a usable AT serial port.
|
||||
quectelVendorID = "2c7c"
|
||||
djiVendorID = "2ca3"
|
||||
dji4GProductID = "4006"
|
||||
)
|
||||
|
||||
type SysFSDiscoverer struct {
|
||||
@@ -44,10 +48,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
if os.IsNotExist(err) {
|
||||
entries = nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||
return nil, fmt.Errorf("discover USB QMI modems: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate modems are identified by kernel driver binding instead of a
|
||||
// vendor-ID whitelist. qmi_wwan only binds Qualcomm QMI control interfaces,
|
||||
// so any USB device with a bound interface exposes a live QMI channel. This
|
||||
// keeps discovery vendor-neutral (SIMCom, Sierra, Telit and other
|
||||
// Qualcomm-based modems are found automatically) while MBIM-only devices
|
||||
// stay out, because cdc_mbim binds their control interface instead and the
|
||||
// project has no MBIM backend.
|
||||
qmiBound := d.qmiWWANBoundDevices()
|
||||
|
||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||
devices := make(map[string]*discoveredUSBDevice)
|
||||
for _, entry := range entries {
|
||||
@@ -75,8 +88,16 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
}
|
||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||
if !isSupportedUSBModem(vendorID, productID) {
|
||||
continue
|
||||
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
||||
// A bound qmi_wwan interface is the strongest vendor-neutral "this is
|
||||
// a live QMI modem" signal, but it excludes Quectel modules running
|
||||
// in a serial or RNDIS/ECM USB composition (no qmi_wwan binding).
|
||||
// Re-admit them by vendor so their AT serial ports stay discoverable;
|
||||
// the candidate is only kept if a ttyUSB/ttyACM node is actually
|
||||
// found below, which is exactly the AT-bearing composition we want.
|
||||
if !isQuectelUSBModem(vendorID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
state := devices[deviceName]
|
||||
@@ -84,7 +105,7 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||
state = &discoveredUSBDevice{
|
||||
candidate: Candidate{
|
||||
ID: candidateID(productID, serialNumber, deviceName),
|
||||
ID: candidateID(vendorID, productID, serialNumber, deviceName),
|
||||
VendorID: vendorID,
|
||||
ProductID: productID,
|
||||
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
||||
@@ -134,6 +155,22 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
})
|
||||
assignQuectelPortRoles(state.candidate.Ports)
|
||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||
if !state.candidate.HasATPort() {
|
||||
// A modem without a usable AT port cannot be driven by vocat, but it
|
||||
// is far more useful to surface it with a discovery issue than to
|
||||
// silently drop it: the operator sees the device is present and gets
|
||||
// told why it is unusable. Two shapes land here:
|
||||
// * qmi_wwan is bound but no ttyUSB/ttyACM exists — the option/qcserial
|
||||
// driver did not claim the serial interfaces (often a missing PID
|
||||
// in its device-ID table, common on Ubuntu for EG25-G carrier
|
||||
// builds). The modem is alive; it just lacks an AT node.
|
||||
// * no qmi_wwan binding (Quectel re-admitted by vendor) and no AT
|
||||
// port — typically an MBIM/RNDIS/ECM composition. The module is on
|
||||
// the bus but exposes no AT serial interface vocat can open.
|
||||
// Both resolve the same operator action: add the PID to the option
|
||||
// driver or switch the module to a QMI+AT composition.
|
||||
state.candidate.DiscoveryIssue = "at_port_missing"
|
||||
}
|
||||
result = append(result, state.candidate)
|
||||
}
|
||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||
@@ -145,11 +182,6 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
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
|
||||
// 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.
|
||||
@@ -158,6 +190,15 @@ func IsDJI4GUSB(vendorID, productID string) bool {
|
||||
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
||||
}
|
||||
|
||||
// isQuectelUSBModem reports whether a USB identity belongs to a Quectel
|
||||
// module. Quectel's serial/RNDIS/ECM compositions (e.g. EC200A at 2c7c:6005)
|
||||
// do not bind qmi_wwan, so discovery must fall back to the vendor ID to keep
|
||||
// them visible. The candidate is only retained if it exposes an AT serial
|
||||
// port, which filters out unrelated Quectel-branded peripherals.
|
||||
func isQuectelUSBModem(vendorID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID)
|
||||
}
|
||||
|
||||
type discoveredWWANDevice struct {
|
||||
index string
|
||||
ports []Port
|
||||
@@ -252,7 +293,7 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||
}
|
||||
if len(group.ports) > 0 {
|
||||
candidate.ATPort = group.ports[0]
|
||||
candidate.ATPort = selectWWANATPort(group.ports)
|
||||
}
|
||||
if len(group.qmiNames) > 0 {
|
||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||
@@ -263,6 +304,20 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// selectWWANATPort prefers the secondary AT port (…at1) over the primary
|
||||
// (…at0) when both exist, falling back to the first AT port otherwise. Some
|
||||
// Qualcomm MHI modems (notably the UFI dongle behind the OpenStick 410) answer
|
||||
// on at1 immediately while at0 delays every response by 10-20 seconds, so the
|
||||
// secondary port is the usable AT channel.
|
||||
func selectWWANATPort(ports []Port) Port {
|
||||
for _, port := range ports {
|
||||
if port.InterfaceNumber == 1 {
|
||||
return port
|
||||
}
|
||||
}
|
||||
return ports[0]
|
||||
}
|
||||
|
||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||
if !strings.HasPrefix(name, "wwan") {
|
||||
return "", "", 0, false
|
||||
@@ -402,7 +457,34 @@ func readSerialAliases(root string) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func candidateID(productID, serialNumber, usbName string) string {
|
||||
// qmiWWANBoundDevices returns the set of USB device paths (for example "1-6"
|
||||
// or the hub-attached "1-4.3.2") that currently have at least one interface
|
||||
// bound to the kernel's qmi_wwan driver. Interface entries in the driver
|
||||
// directory are named "<device-path>:<interface>.<altsetting>", so the part
|
||||
// before the first colon is the owning USB device. The qmi_wwan driver only
|
||||
// binds Qualcomm QMI control interfaces, so membership doubles as a vendor-
|
||||
// neutral "this is a live QMI modem" signal.
|
||||
func (d *SysFSDiscoverer) qmiWWANBoundDevices() map[string]struct{} {
|
||||
driverRoot := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||
entries, err := os.ReadDir(driverRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
devices := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
// The driver directory also holds control files (bind, unbind, uevent,
|
||||
// module, new_id, ...); only names containing a colon are interfaces.
|
||||
deviceName, _, ok := strings.Cut(entry.Name(), ":")
|
||||
if !ok || deviceName == "" {
|
||||
continue
|
||||
}
|
||||
devices[deviceName] = struct{}{}
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
||||
prefix := "usb-" + sanitizeID(vendorID)
|
||||
serialNumber = strings.TrimSpace(serialNumber)
|
||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||
@@ -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
|
||||
// discovery key; configured devices remain stable through ATMapper's
|
||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
}
|
||||
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
||||
return prefix + "-" + sanitizeID(productID+"-"+usbName)
|
||||
}
|
||||
|
||||
func sanitizeID(value string) string {
|
||||
|
||||
@@ -2,24 +2,24 @@ package modem
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSupportedUSBModemIdentity(t *testing.T) {
|
||||
func TestIsDJI4GUSBIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vendorID string
|
||||
productID string
|
||||
want bool
|
||||
}{
|
||||
{name: "Quectel", vendorID: "2c7c", productID: "0125", want: true},
|
||||
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
||||
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
||||
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
||||
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
|
||||
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isSupportedUSBModem(test.vendorID, test.productID); got != test.want {
|
||||
t.Fatalf("isSupportedUSBModem(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||
if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
|
||||
t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||
|
||||
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
||||
candidates, err := discoverer.Discover(context.Background())
|
||||
@@ -57,7 +59,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ID != "quectel-0125-1-6" {
|
||||
if candidate.ID != "usb-2c7c-0125-1-6" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Name != "ttyUSB2" {
|
||||
@@ -101,6 +103,7 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
||||
)
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
@@ -146,6 +149,7 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
||||
}
|
||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||
mustBindQMIWWAN(t, sysRoot, modem.usbName+":1.4")
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
@@ -194,6 +198,7 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||
mustBindQMIWWAN(t, sysRoot, item.usbName+":1.4")
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
@@ -216,9 +221,11 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||
// A plain USB serial adapter (FTDI) exposes ttyUSB but no QMI interface and
|
||||
// is never bound to qmi_wwan, so it must not be treated as a modem.
|
||||
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
||||
@@ -235,6 +242,117 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsNonQuectelVendorBoundToQMIWWAN(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// A Sierra EM7430 flashed to its QMI (rmnet0) composition: non-Quectel
|
||||
// vendor, but its control interface is bound to qmi_wwan.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idVendor"), "1199\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idProduct"), "9077\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "manufacturer"), "Sierra Wireless, Incorporated\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "product"), "EM7430\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-3:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-3:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-3:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.VendorID != "1199" || candidate.Product != "EM7430" {
|
||||
t.Fatalf("candidate = %#v", candidate)
|
||||
}
|
||||
if candidate.ID != "usb-1199-9077-1-3" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if candidate.DiscoveryIssue != "" {
|
||||
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryMarksQMIModemWithoutATPort(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// QMI control interface is bound, but no ttyUSB/ttyACM node exists (for
|
||||
// example the option/qcserial driver does not claim the serial interfaces).
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idProduct"), "0125\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-7:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-7:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||
}
|
||||
if candidate.HasATPort() {
|
||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsHubAttachedQMIWWANDevice(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// Device behind a hub: the USB path "1-4.3.2" contains extra segments, and
|
||||
// the qmi_wwan binding uses the same composite path before the colon.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idProduct"), "0125\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-4.3.2:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-4.3.2:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-4.3.2:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ATPort.Name != "ttyUSB2" {
|
||||
t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if !strings.Contains(candidate.ID, "1-4-3-2") {
|
||||
t.Fatalf("ID = %q, want hub topology in discovery key", candidate.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
@@ -256,7 +374,7 @@ func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||
t.Fatalf("identity = %#v", candidate)
|
||||
}
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at1") || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||
@@ -290,6 +408,23 @@ func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectWWANATPortPrefersSecondaryATPort(t *testing.T) {
|
||||
ports := []Port{
|
||||
{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT},
|
||||
{Name: "wwan0at1", InterfaceNumber: 1, Role: PortRoleAT},
|
||||
}
|
||||
if got := selectWWANATPort(ports); got.Name != "wwan0at1" {
|
||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectWWANATPortFallsBackToPrimaryWhenOnlyAT0(t *testing.T) {
|
||||
ports := []Port{{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT}}
|
||||
if got := selectWWANATPort(ports); got.Name != "wwan0at0" {
|
||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWWANPortName(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, index, kind string
|
||||
@@ -309,6 +444,115 @@ func TestParseWWANPortName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsQuectelSerialModemWithoutQMIWWANBinding(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// A Quectel EC200A in its USB-serial composition (2c7c:6005) exposes ttyUSB
|
||||
// control ports but no qmi_wwan-bound interface, so discovery must re-admit
|
||||
// it by vendor instead of skipping it.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "6005\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-6:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.VendorID != "2c7c" || candidate.ProductID != "6005" {
|
||||
t.Fatalf("candidate = %#v", candidate)
|
||||
}
|
||||
if candidate.ID != "usb-2c7c-6005-1-6" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Name != "ttyUSB2" || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v, want ttyUSB2 at role AT", candidate.ATPort)
|
||||
}
|
||||
if candidate.DiscoveryIssue != "" {
|
||||
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryMarksQuectelPeripheralWithoutATPort(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// A Quectel-branded peripheral exposing only a network interface (no
|
||||
// ttyUSB/ttyACM, no qmi_wwan binding) cannot be driven yet, but vocat
|
||||
// surfaces it with at_port_missing instead of silently dropping it so the
|
||||
// operator sees the device is present and learns what to fix.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-8", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-8", "idProduct"), "6005\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-8:1.0", "net", "enx001122334455"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||
}
|
||||
if candidate.HasATPort() {
|
||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.NetworkInterface != "enx001122334455" {
|
||||
t.Fatalf("network interface = %q", candidate.NetworkInterface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryMarksQuectelMBIMCompositionWithoutATPort(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// An EG25-G in MBIM composition (2c7c:0900) exposes cdc-wdm + net but no
|
||||
// ttyUSB and has no qmi_wwan binding (cdc_mbim binds the control interface
|
||||
// instead). vocat has no MBIM backend, so it must surface the device with
|
||||
// at_port_missing rather than hiding it.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "0900\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-6", "product"), "EG25-G\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "usbmisc", "cdc-wdm0"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "wwp0s20f0u6"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||
}
|
||||
if candidate.HasATPort() {
|
||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.Product != "EG25-G" {
|
||||
t.Fatalf("product = %q", candidate.Product)
|
||||
}
|
||||
// cdc-wdm0 sits under usbmisc/, which scanUSBInterface reports as a QMI
|
||||
// control name; either way the device must appear present, not vanish.
|
||||
if candidate.QMIControl == "" && candidate.NetworkInterface == "" {
|
||||
t.Fatalf("candidate has neither QMI control nor net interface: %#v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
mustMkdir(t, filepath.Dir(path))
|
||||
@@ -323,3 +567,18 @@ func mustMkdir(t *testing.T, path string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustBindQMIWWAN mimics the kernel's driver-binding directory entry: it adds
|
||||
// interfaceName (e.g. "1-6:1.4") under /sys/bus/usb/drivers/qmi_wwan exactly
|
||||
// like the real qmi_wwan driver directory does for a bound QMI interface.
|
||||
func mustBindQMIWWAN(t *testing.T, sysRoot, interfaceName string) {
|
||||
t.Helper()
|
||||
driverDir := filepath.Join(sysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||
if err := os.MkdirAll(driverDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(sysRoot, "bus", "usb", "devices", interfaceName)
|
||||
if err := os.Symlink(target, filepath.Join(driverDir, interfaceName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,14 +148,19 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
||||
if err := ctx.Err(); err != nil {
|
||||
return response, err
|
||||
}
|
||||
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("write %s: %w", command, err)
|
||||
}
|
||||
// Drain the transport before writing the command. Serial transports wait
|
||||
// for any pending output here (a no-op after a synchronous command), while
|
||||
// WWAN transports discard bytes left over from a previous command that
|
||||
// timed out; without this, a late reply (e.g. a slow CGSN response) would
|
||||
// be mis-parsed as this command's output.
|
||||
if err := drainTransport(ctx, session.transport); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||
}
|
||||
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("write %s: %w", command, err)
|
||||
}
|
||||
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 {
|
||||
t.Helper()
|
||||
session, err := NewSession(transport, SessionOptions{
|
||||
|
||||
@@ -108,8 +108,27 @@ func (transport *nativeWWANATTransport) Drain() error {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
// WWAN character-device writes are handed to the modem synchronously and
|
||||
// have no termios output queue to drain.
|
||||
return nil
|
||||
// have no termios output queue to drain. A previous command that timed out
|
||||
// can leave late bytes in the input buffer (e.g. a slow CGSN reply that
|
||||
// arrives after the command deadline); discard them here so the next
|
||||
// command starts from a clean stream instead of mis-parsing stale output.
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := unix.Read(transport.fd, buffer); err != nil {
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//go:build linux
|
||||
|
||||
package modem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// TestNativeWWANATTransportDrainDiscardsPendingBytes verifies Drain discards
|
||||
// every byte already buffered on the transport. A command that timed out (e.g.
|
||||
// AT+CGSN on an MHI modem that never answers OK) can leave its late reply in
|
||||
// the input buffer; the next command's Drain must clear it, however much data
|
||||
// is pending, before the session writes the new command.
|
||||
func TestNativeWWANATTransportDrainDiscardsPendingBytes(t *testing.T) {
|
||||
readFD, writeFD := socketpair(t)
|
||||
defer unix.Close(writeFD)
|
||||
|
||||
// More than one 4096-byte Drain read: a slow CGSN reply (echo + IMEI +
|
||||
// trailing CRLF) can exceed a single buffer.
|
||||
payload := make([]byte, 12000)
|
||||
for index := range payload {
|
||||
payload[index] = byte('A' + index%26)
|
||||
}
|
||||
payload = append(payload, []byte("\r\n+CGSN: 357091089453326\r\n")...)
|
||||
if _, err := unix.Write(writeFD, payload); err != nil {
|
||||
t.Fatalf("seed stale bytes: %v", err)
|
||||
}
|
||||
|
||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||
if err := transport.Drain(); err != nil {
|
||||
t.Fatalf("Drain: %v", err)
|
||||
}
|
||||
assertNoPendingBytes(t, readFD, "after Drain")
|
||||
|
||||
// Draining a clean transport is a fast no-op that must not block or error.
|
||||
if err := transport.Drain(); err != nil {
|
||||
t.Fatalf("second Drain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNativeWWANATTransportDrainRejectsClosedTransport covers the guard that
|
||||
// keeps a poisoned session from draining a wedged, already-closed fd.
|
||||
func TestNativeWWANATTransportDrainRejectsClosedTransport(t *testing.T) {
|
||||
readFD, writeFD := socketpair(t)
|
||||
defer unix.Close(writeFD)
|
||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||
if err := transport.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := transport.Drain(); !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("Drain after Close = %v, want ErrClosedPipe", err)
|
||||
}
|
||||
}
|
||||
|
||||
func socketpair(t *testing.T) (int, int) {
|
||||
t.Helper()
|
||||
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fds[0], fds[1]
|
||||
}
|
||||
|
||||
func assertNoPendingBytes(t *testing.T, fd int, context string) {
|
||||
t.Helper()
|
||||
fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("poll %s: %v", context, err)
|
||||
}
|
||||
if ready != 0 {
|
||||
t.Fatalf("%s: fd still readable", context)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package netguard
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -48,6 +49,13 @@ func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url
|
||||
// rejects private/special-use destinations at dial time, and validates every
|
||||
// redirect before following it.
|
||||
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
|
||||
return NewPublicHTTPClientWithRootCAs(timeout, requireHTTPS, nil)
|
||||
}
|
||||
|
||||
// NewPublicHTTPClientWithRootCAs creates the same guarded client while using
|
||||
// the supplied trust pool for protocols whose standards define additional
|
||||
// public roots beyond the host operating system's CA bundle.
|
||||
func NewPublicHTTPClientWithRootCAs(timeout time.Duration, requireHTTPS bool, roots *x509.CertPool) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
@@ -60,6 +68,7 @@ func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: roots,
|
||||
},
|
||||
}
|
||||
return &http.Client{
|
||||
|
||||
+246
-1
@@ -3,6 +3,8 @@ package proxy
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,18 +19,44 @@ type ProbeResult struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
HandshakeOK bool `json:"handshake_ok"`
|
||||
UDPAssociateOK bool `json:"udp_associate_ok"`
|
||||
UDPExchangeOK bool `json:"udp_exchange_ok"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
RelayAddr string `json:"relay_addr,omitempty"`
|
||||
DNSServer string `json:"dns_server,omitempty"`
|
||||
DNSName string `json:"dns_name,omitempty"`
|
||||
DNSRCode int `json:"dns_rcode,omitempty"`
|
||||
RoundTripMS int64 `json:"round_trip_ms,omitempty"`
|
||||
Diagnosis string `json:"diagnosis,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultProbeDNSServer = "1.1.1.1:53"
|
||||
defaultProbeDNSName = "example.com"
|
||||
)
|
||||
|
||||
func ProbeSOCKS5(
|
||||
ctx context.Context,
|
||||
address string,
|
||||
username string,
|
||||
password string,
|
||||
timeout time.Duration,
|
||||
) (ProbeResult, error) {
|
||||
return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName)
|
||||
}
|
||||
|
||||
// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real
|
||||
// UDP DNS round trip through the returned relay. Keeping the target injectable
|
||||
// makes the negative paths deterministic in tests without weakening the
|
||||
// production probe.
|
||||
func probeSOCKS5(
|
||||
ctx context.Context,
|
||||
address string,
|
||||
username string,
|
||||
password string,
|
||||
timeout time.Duration,
|
||||
dnsServer string,
|
||||
dnsName string,
|
||||
) (ProbeResult, error) {
|
||||
address = strings.TrimSpace(address)
|
||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||
@@ -122,11 +150,228 @@ func ProbeSOCKS5(
|
||||
port := int(portBytes[0])<<8 | int(portBytes[1])
|
||||
result.UDPAssociateOK = true
|
||||
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
result.DNSServer = dnsServer
|
||||
result.DNSName = dnsName
|
||||
|
||||
if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil {
|
||||
if result.Diagnosis == "" {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
}
|
||||
if result.Hint == "" {
|
||||
result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
result.Diagnosis = "ready"
|
||||
result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。")
|
||||
result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func probeUDPExchange(
|
||||
ctx context.Context,
|
||||
control net.Conn,
|
||||
result *ProbeResult,
|
||||
relayHost string,
|
||||
relayPort int,
|
||||
dnsServer string,
|
||||
dnsName string,
|
||||
timeout time.Duration,
|
||||
) error {
|
||||
if result == nil {
|
||||
return errors.New("proxy: probe result is nil")
|
||||
}
|
||||
dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer))
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_target"
|
||||
return fmt.Errorf("proxy: resolve UDP probe target: %w", err)
|
||||
}
|
||||
relayHost = strings.TrimSpace(relayHost)
|
||||
if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() {
|
||||
remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String())
|
||||
if splitErr != nil {
|
||||
result.Diagnosis = "invalid_udp_relay"
|
||||
return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr)
|
||||
}
|
||||
relayHost = remoteHost
|
||||
}
|
||||
relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort)))
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_udp_relay"
|
||||
return fmt.Errorf("proxy: resolve UDP relay: %w", err)
|
||||
}
|
||||
|
||||
localNetwork := "udp4"
|
||||
if relayAddress.IP != nil && relayAddress.IP.To4() == nil {
|
||||
localNetwork = "udp6"
|
||||
}
|
||||
udpConnection, err := net.ListenUDP(localNetwork, nil)
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_socket_failed"
|
||||
return fmt.Errorf("proxy: open UDP probe socket: %w", err)
|
||||
}
|
||||
defer udpConnection.Close()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
}
|
||||
if err := udpConnection.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("proxy: set UDP probe deadline: %w", err)
|
||||
}
|
||||
|
||||
query, queryID, err := buildDNSQuery(dnsName)
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_name"
|
||||
return err
|
||||
}
|
||||
datagram, err := buildSOCKSUDPDatagram(dnsAddress, query)
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_target"
|
||||
return err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil {
|
||||
result.Diagnosis = "udp_send_failed"
|
||||
return fmt.Errorf("proxy: send UDP DNS probe: %w", err)
|
||||
}
|
||||
|
||||
responseBuffer := make([]byte, 64*1024)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err)
|
||||
}
|
||||
count, sender, err := udpConnection.ReadFromUDP(responseBuffer)
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err)
|
||||
}
|
||||
if !sameUDPAddress(sender, relayAddress) {
|
||||
continue
|
||||
}
|
||||
payload, err := parseSOCKSUDPDatagram(responseBuffer[:count])
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_invalid_response"
|
||||
return fmt.Errorf("proxy: parse UDP relay response: %w", err)
|
||||
}
|
||||
rcode, err := validateDNSResponse(payload, queryID)
|
||||
if err != nil {
|
||||
result.Diagnosis = "dns_invalid_response"
|
||||
return err
|
||||
}
|
||||
result.UDPExchangeOK = true
|
||||
result.DNSRCode = rcode
|
||||
result.RoundTripMS = time.Since(startedAt).Milliseconds()
|
||||
if result.RoundTripMS < 1 {
|
||||
result.RoundTripMS = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildDNSQuery(name string) ([]byte, uint16, error) {
|
||||
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||
if name == "" || len(name) > 253 {
|
||||
return nil, 0, errors.New("proxy: UDP probe DNS name is invalid")
|
||||
}
|
||||
var idBytes [2]byte
|
||||
if _, err := rand.Read(idBytes[:]); err != nil {
|
||||
return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err)
|
||||
}
|
||||
queryID := binary.BigEndian.Uint16(idBytes[:])
|
||||
query := make([]byte, 12, 12+len(name)+6)
|
||||
binary.BigEndian.PutUint16(query[0:2], queryID)
|
||||
binary.BigEndian.PutUint16(query[2:4], 0x0100)
|
||||
binary.BigEndian.PutUint16(query[4:6], 1)
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return nil, 0, errors.New("proxy: UDP probe DNS label is invalid")
|
||||
}
|
||||
query = append(query, byte(len(label)))
|
||||
query = append(query, label...)
|
||||
}
|
||||
query = append(query, 0, 0, 1, 0, 1)
|
||||
return query, queryID, nil
|
||||
}
|
||||
|
||||
func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||
if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 {
|
||||
return nil, errors.New("proxy: UDP target is invalid")
|
||||
}
|
||||
packet := []byte{0, 0, 0}
|
||||
if ipv4 := target.IP.To4(); ipv4 != nil {
|
||||
packet = append(packet, 1)
|
||||
packet = append(packet, ipv4...)
|
||||
} else if ipv6 := target.IP.To16(); ipv6 != nil {
|
||||
packet = append(packet, 4)
|
||||
packet = append(packet, ipv6...)
|
||||
} else {
|
||||
return nil, errors.New("proxy: UDP target address family is invalid")
|
||||
}
|
||||
packet = append(packet, byte(target.Port>>8), byte(target.Port))
|
||||
packet = append(packet, payload...)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) {
|
||||
if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 {
|
||||
return nil, errors.New("invalid SOCKS5 UDP header")
|
||||
}
|
||||
if packet[2] != 0 {
|
||||
return nil, errors.New("fragmented SOCKS5 UDP response is unsupported")
|
||||
}
|
||||
offset := 4
|
||||
switch packet[3] {
|
||||
case 1:
|
||||
offset += net.IPv4len
|
||||
case 3:
|
||||
if len(packet) <= offset {
|
||||
return nil, errors.New("truncated SOCKS5 UDP domain")
|
||||
}
|
||||
offset += 1 + int(packet[offset])
|
||||
case 4:
|
||||
offset += net.IPv6len
|
||||
default:
|
||||
return nil, errors.New("unsupported SOCKS5 UDP address type")
|
||||
}
|
||||
if offset+2 > len(packet) {
|
||||
return nil, errors.New("truncated SOCKS5 UDP endpoint")
|
||||
}
|
||||
offset += 2
|
||||
if offset >= len(packet) {
|
||||
return nil, errors.New("empty SOCKS5 UDP payload")
|
||||
}
|
||||
return packet[offset:], nil
|
||||
}
|
||||
|
||||
func validateDNSResponse(payload []byte, queryID uint16) (int, error) {
|
||||
if len(payload) < 12 {
|
||||
return 0, errors.New("proxy: DNS response is truncated")
|
||||
}
|
||||
if binary.BigEndian.Uint16(payload[0:2]) != queryID {
|
||||
return 0, errors.New("proxy: DNS response ID does not match")
|
||||
}
|
||||
flags := binary.BigEndian.Uint16(payload[2:4])
|
||||
if flags&0x8000 == 0 {
|
||||
return 0, errors.New("proxy: DNS response is not a response")
|
||||
}
|
||||
rcode := int(flags & 0x000f)
|
||||
if rcode != 0 {
|
||||
return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode)
|
||||
}
|
||||
return rcode, nil
|
||||
}
|
||||
|
||||
func sameUDPAddress(left, right *net.UDPAddr) bool {
|
||||
if left == nil || right == nil || left.Port != right.Port {
|
||||
return false
|
||||
}
|
||||
if left.IP == nil || right.IP == nil {
|
||||
return true
|
||||
}
|
||||
return left.IP.Equal(right.IP)
|
||||
}
|
||||
|
||||
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
||||
switch addressType {
|
||||
case 1:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) {
|
||||
address, stop := startProbeSOCKS5Server(t, false)
|
||||
defer stop()
|
||||
|
||||
result, err := probeSOCKS5(
|
||||
context.Background(),
|
||||
address,
|
||||
"",
|
||||
"",
|
||||
250*time.Millisecond,
|
||||
"192.0.2.53:53",
|
||||
"example.test",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data")
|
||||
}
|
||||
if !result.UDPAssociateOK {
|
||||
t.Fatal("UDP ASSOCIATE should have succeeded")
|
||||
}
|
||||
if result.UDPExchangeOK {
|
||||
t.Fatal("UDP exchange should not be reported as successful")
|
||||
}
|
||||
if result.Diagnosis != "udp_no_roundtrip" {
|
||||
t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) {
|
||||
address, stop := startProbeSOCKS5Server(t, true)
|
||||
defer stop()
|
||||
|
||||
result, err := probeSOCKS5(
|
||||
context.Background(),
|
||||
address,
|
||||
"",
|
||||
"",
|
||||
time.Second,
|
||||
"192.0.2.53:53",
|
||||
"example.test",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Probe returned error: %v", err)
|
||||
}
|
||||
if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK {
|
||||
t.Fatalf("Probe evidence incomplete: %+v", result)
|
||||
}
|
||||
if result.Diagnosis != "ready" {
|
||||
t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis)
|
||||
}
|
||||
if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" {
|
||||
t.Fatalf("Unexpected DNS evidence: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) {
|
||||
t.Helper()
|
||||
udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatalf("ListenUDP: %v", err)
|
||||
}
|
||||
tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
udpConnection.Close()
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
|
||||
if echoDNS {
|
||||
go func() {
|
||||
buffer := make([]byte, 2048)
|
||||
count, sender, readErr := udpConnection.ReadFromUDP(buffer)
|
||||
if readErr != nil || count < 22 {
|
||||
return
|
||||
}
|
||||
// The test target is IPv4, so the SOCKS5 UDP header is ten bytes.
|
||||
buffer[12] = 0x81
|
||||
buffer[13] = 0x80
|
||||
_, _ = udpConnection.WriteToUDP(buffer[:count], sender)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
connection, acceptErr := tcpListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
greeting := make([]byte, 3)
|
||||
if _, readErr := io.ReadFull(connection, greeting); readErr != nil {
|
||||
return
|
||||
}
|
||||
if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil {
|
||||
return
|
||||
}
|
||||
associate := make([]byte, 10)
|
||||
if _, readErr := io.ReadFull(connection, associate); readErr != nil {
|
||||
return
|
||||
}
|
||||
udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port
|
||||
response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)}
|
||||
if _, writeErr := connection.Write(response); writeErr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, connection)
|
||||
}()
|
||||
|
||||
return tcpListener.Addr().String(), func() {
|
||||
_ = tcpListener.Close()
|
||||
_ = udpConnection.Close()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -22,12 +31,42 @@ func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||
"AT+CSQ;+CMSS=7",
|
||||
"AT+CSQ;D12345;",
|
||||
} {
|
||||
if err := validateATCommand(command); err == nil {
|
||||
if err := validateATCommand(command, false); err == nil {
|
||||
t.Errorf("validateATCommand(%q) permitted a guarded mutation", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateATCommandForceBypassesGuard(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, command := range []string{
|
||||
"AT+CGATT=1",
|
||||
"AT+CFUN=1",
|
||||
"AT+CGACT=1,1",
|
||||
"AT+CUSD=1,\"*100#\"",
|
||||
"ATD12345;",
|
||||
} {
|
||||
if err := validateATCommand(command, true); err != nil {
|
||||
t.Errorf("validateATCommand(%q, true): %v", command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateATCommandForceKeepsSyntaxChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, command := range []string{
|
||||
"A",
|
||||
"",
|
||||
"AT\r",
|
||||
"AT\n",
|
||||
string(make([]byte, 513)),
|
||||
} {
|
||||
if err := validateATCommand(command, true); err == nil {
|
||||
t.Errorf("validateATCommand(%q, true) skipped syntax check", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, command := range []string{
|
||||
@@ -39,8 +78,67 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
||||
"AT+CIMI",
|
||||
"AT+CCID",
|
||||
} {
|
||||
if err := validateATCommand(command); err != nil {
|
||||
if err := validateATCommand(command, false); err != nil {
|
||||
t.Errorf("validateATCommand(%q): %v", command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AT terminal must present ERROR / +CME ERROR as a normal response, not as
|
||||
// a 502. Before the CommandError branch was restored, every unsupported or
|
||||
// SIM-less command was folded into "the device operation failed", hiding the
|
||||
// real reason from the user.
|
||||
func TestHandleATSurfacesCommandErrorAsResponse(t *testing.T) {
|
||||
controller := fakeDeviceController{
|
||||
entry: device.Device{ID: "dev1"},
|
||||
atHandler: func(command string) (modem.Response, error) {
|
||||
return modem.Response{}, &modem.CommandError{
|
||||
Command: command,
|
||||
Final: "+CME ERROR: 10",
|
||||
Lines: []string{"+CME ERROR: 10"},
|
||||
}
|
||||
},
|
||||
}
|
||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/devices/dev1/actions/at",
|
||||
strings.NewReader(`{"cmd":"AT+CPIN?","timeout_ms":5000}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if !server.handleAT(recorder, request, "dev1") {
|
||||
t.Fatal("handleAT returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
response, _ := data["response"].(string)
|
||||
if !strings.Contains(response, "+CME ERROR: 10") {
|
||||
t.Fatalf("response = %q, want +CME ERROR text", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleATMapsNonCommandErrorTo502(t *testing.T) {
|
||||
controller := fakeDeviceController{
|
||||
entry: device.Device{ID: "dev1"},
|
||||
atErr: errors.New("transport wedged"),
|
||||
}
|
||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/devices/dev1/actions/at",
|
||||
strings.NewReader(`{"cmd":"AT+CSQ"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if !server.handleAT(recorder, request, "dev1") {
|
||||
t.Fatal("handleAT returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
||||
}, "\n"),
|
||||
Time: run.FinishedAt, Task: task, Run: run,
|
||||
}
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
@@ -91,6 +91,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
|
||||
+110
-12
@@ -581,7 +581,7 @@ func (s *Server) handleDevicePath(
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleUSSD(w, r, physicalID)
|
||||
return s.handleUSSD(w, r, config, physicalID)
|
||||
case "actions/ussd/continue":
|
||||
return s.handleUSSDContinue(w, r)
|
||||
case "actions/ussd/cancel":
|
||||
@@ -1045,13 +1045,14 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
var request struct {
|
||||
Command string `json:"cmd"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
command := strings.TrimSpace(request.Command)
|
||||
if err := validateATCommand(command); err != nil {
|
||||
if err := validateATCommand(command, request.Force); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "unsafe_at_command", err.Error())
|
||||
return true
|
||||
}
|
||||
@@ -1059,6 +1060,26 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
defer cancel()
|
||||
response, err := s.devices.ExecuteAT(ctx, id, command)
|
||||
if err != nil {
|
||||
var commandErr *modem.CommandError
|
||||
if errors.As(err, &commandErr) {
|
||||
// The modem answered with ERROR / +CME ERROR. An AT terminal must
|
||||
// surface that text (including the CME detail) as a normal response;
|
||||
// folding it into a 502 hides the real reason from the user.
|
||||
text := strings.Join(commandErr.Lines, "\n")
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += commandErr.Final
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"response": text,
|
||||
"final": commandErr.Final,
|
||||
"duration_ms": 0,
|
||||
"urcs": []string{},
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
@@ -1080,7 +1101,7 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
return true
|
||||
}
|
||||
|
||||
func validateATCommand(command string) error {
|
||||
func validateATCommand(command string, force bool) error {
|
||||
upper := strings.ToUpper(command)
|
||||
if len(command) < 2 || len(command) > 512 || !strings.HasPrefix(upper, "AT") {
|
||||
return errors.New("AT command must start with AT and contain at most 512 characters")
|
||||
@@ -1088,6 +1109,9 @@ func validateATCommand(command string) error {
|
||||
if strings.ContainsAny(command, "\r\n\x00") {
|
||||
return errors.New("AT command must contain exactly one line")
|
||||
}
|
||||
if force {
|
||||
return nil
|
||||
}
|
||||
canonical := strings.NewReplacer(" ", "", "\t", "").Replace(upper)
|
||||
for _, blocked := range []string{
|
||||
`+QCFG="USBNET"`,
|
||||
@@ -1118,7 +1142,34 @@ func validateATCommand(command string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) bool {
|
||||
// imsUSSIController is the optional VoWiFi runtime capability used to route a
|
||||
// USSD request over IMS (3GPP TS 24.390) when VoWiFi is enabled and the IMS
|
||||
// session is registered. device.Manager does not implement it; the VoWiFi
|
||||
// runtime manager does.
|
||||
type imsUSSIController interface {
|
||||
SendUSSI(context.Context, string, vowifi.USSISubmitRequest) (vowifi.USSISubmitResult, error)
|
||||
}
|
||||
|
||||
// openUSSDSession mirrors device.Manager.openUSSDSession but lives on the HTTP
|
||||
// server so a USSI awaiting-input reply can hand back a token the existing
|
||||
// continue/cancel endpoints understand. The token is only a device handle;
|
||||
// the IMS session owns the actual dialog.
|
||||
func (s *Server) openUSSDSession(deviceID string) string {
|
||||
return s.ussdSessions.open(deviceID)
|
||||
}
|
||||
|
||||
// ussdSessionDevice resolves a USSD session token created by openUSSDSession
|
||||
// back to its device id, matching device.ErrUSSDSessionNotFound semantics.
|
||||
func (s *Server) ussdSessionDevice(sessionID string) (string, error) {
|
||||
return s.ussdSessions.device(sessionID)
|
||||
}
|
||||
|
||||
// dropUSSDSession releases a USSD session token.
|
||||
func (s *Server) dropUSSDSession(sessionID string) {
|
||||
s.ussdSessions.drop(sessionID)
|
||||
}
|
||||
|
||||
func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, config store.Device, id string) bool {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
@@ -1132,21 +1183,61 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) b
|
||||
}
|
||||
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
||||
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)
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"result": result.Text,
|
||||
"raw": result.Raw,
|
||||
"dcs": result.DCS,
|
||||
},
|
||||
})
|
||||
writeUSSDResult(w, result)
|
||||
return true
|
||||
}
|
||||
|
||||
// ussdResultFromUSSI maps a USSI result onto the device.USSDResult shape that
|
||||
// writeUSSDResult expects. A USSI awaiting-input reply opens a server-side
|
||||
// session token via the device manager so the existing continue/cancel
|
||||
// endpoints keep working; the token maps back to the device and the continue
|
||||
// handler re-enters the USSI path through the same imsUSSIController.
|
||||
func ussdResultFromUSSI(result vowifi.USSISubmitResult, deviceID string, server *Server) device.USSDResult {
|
||||
mapped := device.USSDResult{
|
||||
Text: result.Text,
|
||||
Raw: result.Raw,
|
||||
DCS: result.DCS,
|
||||
Status: result.Status,
|
||||
Continueable: result.Continueable,
|
||||
}
|
||||
// USSI has no inline continue/terminate flag in the 2xx response body, so
|
||||
// treat any non-empty successful reply as potentially multi-round. The cancel
|
||||
// endpoint drops the local token; the network will time the dialog out if it
|
||||
// was actually final.
|
||||
if mapped.Status != "failed" && mapped.Status != "terminated" && mapped.Text != "" {
|
||||
mapped.Status = "awaiting_input"
|
||||
mapped.Continueable = true
|
||||
mapped.SessionID = server.openUSSDSession(deviceID)
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
|
||||
if !requireMethod(w, r, http.MethodPatch) {
|
||||
return true
|
||||
@@ -1616,7 +1707,14 @@ func (s *Server) configuredDeviceOverview(
|
||||
result["id"] = config.ID
|
||||
result["name"] = config.Name
|
||||
result["interface"] = config.Interface
|
||||
result["at_port"] = config.ATPort
|
||||
// ttyUSB allocation changes across USB reconnects and boot cycles. The AT
|
||||
// terminal must use only the currently discovered physical port; a stored
|
||||
// path may point at another modem after enumeration order changes.
|
||||
liveATPort := ""
|
||||
if present {
|
||||
liveATPort = entry.Candidate.ATPort.OpenPath()
|
||||
}
|
||||
result["at_port"] = liveATPort
|
||||
result["audio_device"] = config.AudioDevice
|
||||
result["backend_mode"] = config.DeviceBackend
|
||||
result["control_device"] = config.ControlDevice
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
// overviewStreamInterval is the cadence at which the overview SSE stream pushes
|
||||
@@ -192,6 +193,31 @@ func (s *Server) handleUSSDContinue(w http.ResponseWriter, r *http.Request) bool
|
||||
input := firstNonEmpty(request.Input, request.Command)
|
||||
ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
|
||||
defer cancel()
|
||||
// A session opened by the USSI path maps back to a device id that may still
|
||||
// be VoWiFi-active. Prefer USSI continue when IMS is ready; otherwise report
|
||||
// the session as unavailable rather than falling through to the cellular
|
||||
// CUSD path, because the IMS session owns the actual dialog.
|
||||
if deviceID, sessionErr := s.ussdSessionDevice(sessionID); sessionErr == nil {
|
||||
if config, configErr := s.store.Device(r.Context(), deviceID); configErr == nil &&
|
||||
config.VoWiFiEnabled && s.vowifi != nil {
|
||||
if sender, ok := s.vowifi.(imsUSSIController); ok {
|
||||
if state, stateErr := s.vowifi.State(deviceID); stateErr == nil && state.IMSReady {
|
||||
result, sendErr := sender.SendUSSI(ctx, deviceID, vowifi.USSISubmitRequest{Input: input})
|
||||
if sendErr == nil {
|
||||
writeUSSDResult(w, ussdResultFromUSSI(result, deviceID, s))
|
||||
return true
|
||||
}
|
||||
if !errors.Is(sendErr, vowifi.ErrUSSINotReady) {
|
||||
s.writeDeviceError(w, sendErr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, "ussi_session_unavailable",
|
||||
"USSI session is no longer available because the IMS registration has dropped")
|
||||
return true
|
||||
}
|
||||
result, err := s.devices.ContinueUSSD(ctx, sessionID, input)
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
@@ -219,6 +245,16 @@ func (s *Server) handleUSSDCancel(w http.ResponseWriter, r *http.Request) bool {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required")
|
||||
return true
|
||||
}
|
||||
// Drop a USSI-originated session token locally. USSI has no network-side
|
||||
// release signalling in the minimal implementation, so dropping the handle
|
||||
// matches the cellular AT+CUSD=2 "best-effort abort" behavior.
|
||||
if _, sessionErr := s.ussdSessionDevice(sessionID); sessionErr == nil {
|
||||
s.dropUSSDSession(sessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{"cancelled": true, "session_id": sessionID},
|
||||
})
|
||||
return true
|
||||
}
|
||||
if err := s.devices.CancelUSSD(r.Context(), sessionID); err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
@@ -266,6 +267,143 @@ func TestHandleUSSDContinueRequiresSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeUSSIController implements both VoWiFiController and the optional
|
||||
// imsUSSIController interface so the HTTP layer USSI path can be exercised
|
||||
// without a real runtime manager.
|
||||
type fakeUSSIController struct {
|
||||
fakeVoWiFiController
|
||||
sendErr error
|
||||
sendResult vowifi.USSISubmitResult
|
||||
sendCalled int
|
||||
lastInput string
|
||||
}
|
||||
|
||||
func (controller *fakeUSSIController) SendUSSI(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
request vowifi.USSISubmitRequest,
|
||||
) (vowifi.USSISubmitResult, error) {
|
||||
controller.sendCalled++
|
||||
controller.lastInput = request.Input
|
||||
if request.Code != "" {
|
||||
controller.lastInput = request.Code
|
||||
}
|
||||
return controller.sendResult, controller.sendErr
|
||||
}
|
||||
|
||||
func TestHandleUSSDRoutesOverIMSWhenReady(t *testing.T) {
|
||||
controller := &fakeUSSIController{
|
||||
fakeVoWiFiController: fakeVoWiFiController{state: vowifi.State{IMSReady: true}},
|
||||
sendResult: vowifi.USSISubmitResult{Status: "final", Text: "IMS balance"},
|
||||
}
|
||||
devices := fakeDeviceController{ussdResult: device.USSDResult{Status: "final", Text: "cellular"}}
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
devices: devices,
|
||||
vowifi: controller,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd", strings.NewReader(`{"command":"*100#"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSD(recorder, request, store.Device{ID: "dev1", VoWiFiEnabled: true}, "dev1")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
result, _ := data["result"].(map[string]any)
|
||||
if result["text"] != "IMS balance" {
|
||||
t.Fatalf("result = %v, want IMS routed response", result)
|
||||
}
|
||||
if controller.sendCalled != 1 {
|
||||
t.Fatalf("SendUSSI called %d times, want 1", controller.sendCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUSSDFallsBackToCellularWhenIMSNotReady(t *testing.T) {
|
||||
controller := &fakeUSSIController{
|
||||
fakeVoWiFiController: fakeVoWiFiController{state: vowifi.State{}},
|
||||
}
|
||||
devices := fakeDeviceController{ussdResult: device.USSDResult{Status: "final", Text: "cellular"}}
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
devices: devices,
|
||||
vowifi: controller,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd", strings.NewReader(`{"command":"*100#"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSD(recorder, request, store.Device{ID: "dev1", VoWiFiEnabled: true}, "dev1")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
result, _ := data["result"].(map[string]any)
|
||||
if result["text"] != "cellular" {
|
||||
t.Fatalf("result = %v, want cellular fallback", result)
|
||||
}
|
||||
if controller.sendCalled != 0 {
|
||||
t.Fatalf("SendUSSI called %d times, want 0", controller.sendCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUSSDContinueUsesIMSForUSSIPersistedSession(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "test", DeviceType: store.DeviceTypePCIeEC20EC25, VoWiFiEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &fakeUSSIController{
|
||||
fakeVoWiFiController: fakeVoWiFiController{state: vowifi.State{IMSReady: true}},
|
||||
sendResult: vowifi.USSISubmitResult{Status: "awaiting_input", Text: "Sub-menu"},
|
||||
}
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
store: database,
|
||||
vowifi: controller,
|
||||
}
|
||||
sessionID := server.openUSSDSession("dev1")
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd/continue", strings.NewReader(`{"session_id":"`+sessionID+`","input":"1"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSDContinue(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
result, _ := data["result"].(map[string]any)
|
||||
if result["text"] != "Sub-menu" {
|
||||
t.Fatalf("result = %v, want IMS continue response", result)
|
||||
}
|
||||
if controller.sendCalled != 1 || controller.lastInput != "1" {
|
||||
t.Fatalf("SendUSSI called %d times with input %q, want 1/1", controller.sendCalled, controller.lastInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUSSDCancelDropsUSSIPersistedSession(t *testing.T) {
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
maxRequestBodyBytes: 4096,
|
||||
vowifi: &fakeUSSIController{},
|
||||
}
|
||||
sessionID := server.openUSSDSession("dev1")
|
||||
request := httptest.NewRequest(http.MethodPost, "/actions/ussd/cancel", strings.NewReader(`{"session_id":"`+sessionID+`"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUSSDCancel(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if _, err := server.ussdSessionDevice(sessionID); !errors.Is(err, device.ErrUSSDSessionNotFound) {
|
||||
t.Fatalf("session token was not dropped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardPoliciesListsAll(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
|
||||
@@ -134,6 +134,29 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceOverviewAlwaysUsesLiveDiscoveredATPort(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
s := &Server{store: database}
|
||||
config := store.Device{ID: "ec20_1", ATPort: "/dev/ttyUSB9"}
|
||||
entry := device.Device{Candidate: modem.Candidate{
|
||||
ATPort: modem.Port{Path: "/dev/ttyUSB2", Role: modem.PortRoleAT},
|
||||
}}
|
||||
|
||||
connected := s.configuredDeviceOverview(config, entry, true)
|
||||
if got := connected["at_port"]; got != "/dev/ttyUSB2" {
|
||||
t.Fatalf("connected AT port = %#v, want live /dev/ttyUSB2", got)
|
||||
}
|
||||
|
||||
offline := s.configuredDeviceOverview(config, entry, false)
|
||||
if got := offline["at_port"]; got != "" {
|
||||
t.Fatalf("offline AT port = %#v, want empty instead of stored port", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
||||
for _, snapshot := range []*device.Snapshot{
|
||||
{IMEI: "867123456789012"},
|
||||
|
||||
@@ -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)
|
||||
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
||||
if probeErr == nil && probe.UDPAssociateOK {
|
||||
message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。")
|
||||
if probeErr == nil && probe.UDPExchangeOK {
|
||||
message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -425,8 +425,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id
|
||||
8*time.Second,
|
||||
)
|
||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||
if probeErr == nil && result.UDPAssociateOK {
|
||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||
if probeErr == nil && result.UDPExchangeOK {
|
||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -479,8 +479,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques
|
||||
8*time.Second,
|
||||
)
|
||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||
if probeErr == nil && result.UDPAssociateOK {
|
||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||
if probeErr == nil && result.UDPExchangeOK {
|
||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
|
||||
@@ -57,6 +57,7 @@ type Server struct {
|
||||
auth *auth.Service
|
||||
devices DeviceController
|
||||
vowifi VoWiFiController
|
||||
ussdSessions ussdSessionStore
|
||||
logs *loghub.Hub
|
||||
assets fs.FS
|
||||
indexHTML []byte
|
||||
@@ -117,6 +118,7 @@ func New(options Options) (*Server, error) {
|
||||
auth: options.Auth,
|
||||
devices: options.Devices,
|
||||
vowifi: options.VoWiFi,
|
||||
ussdSessions: newUSSDSessionStore(),
|
||||
logs: options.Logs,
|
||||
assets: options.Assets,
|
||||
indexHTML: indexHTML,
|
||||
@@ -144,6 +146,9 @@ func New(options Options) (*Server, error) {
|
||||
server.loadUILanguage(context.Background())
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", server.handleLiveness)
|
||||
mux.HandleFunc("/readyz", server.handleReadiness)
|
||||
mux.HandleFunc("/metrics", server.handleMetrics)
|
||||
mux.HandleFunc("/api/health", server.handleHealth)
|
||||
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
||||
mux.HandleFunc("/api/auth/session", server.handleSession)
|
||||
|
||||
@@ -41,6 +41,7 @@ var notificationChannels = []string{
|
||||
"bark",
|
||||
"pushplus",
|
||||
"wecom",
|
||||
"lark",
|
||||
}
|
||||
|
||||
var notificationFields = map[string]map[string]string{
|
||||
@@ -65,6 +66,9 @@ var notificationFields = map[string]map[string]string{
|
||||
"wecom": {
|
||||
"urls": "strings", "payload_template": "string",
|
||||
},
|
||||
"lark": {
|
||||
"url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string",
|
||||
},
|
||||
}
|
||||
|
||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||
@@ -240,6 +244,20 @@ func decodeNotificationConfig(
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
||||
}
|
||||
if enabled && channel == "lark" {
|
||||
var resolved map[string]any
|
||||
if err := json.Unmarshal(config, &resolved); err != nil {
|
||||
return false, nil, fmt.Errorf("decode lark notification config: %w", err)
|
||||
}
|
||||
signingEnabled, _ := resolved["signing_enabled"].(bool)
|
||||
if signingEnabled && configString(resolved, "url") != store.SecretMask &&
|
||||
configString(resolved, "secret") == store.SecretMask {
|
||||
return false, nil, errors.New("lark.secret must be re-entered when lark.url changes")
|
||||
}
|
||||
if err := validateLarkNotificationConfig(resolved); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
return enabled, config, nil
|
||||
}
|
||||
|
||||
@@ -265,6 +283,9 @@ func validateNotificationField(
|
||||
if name == "text_template" {
|
||||
limit = 32768
|
||||
}
|
||||
if channel == "lark" && name == "payload_template" {
|
||||
limit = maxLarkPayloadBytes
|
||||
}
|
||||
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
||||
}
|
||||
@@ -274,7 +295,7 @@ func validateNotificationField(
|
||||
}
|
||||
}
|
||||
if name == "proxy" && value != "" {
|
||||
if _, err := parseOutboundURL(value, false); err != nil {
|
||||
if _, err := parseProxyURL(value); err != nil {
|
||||
return fmt.Errorf("%s is not a valid HTTP URL", field)
|
||||
}
|
||||
}
|
||||
@@ -300,6 +321,16 @@ func validateNotificationField(
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
if channel == "lark" && name == "payload_template" && value != "" {
|
||||
if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil {
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
if channel == "lark" && name == "url" && value != "" && value != store.SecretMask {
|
||||
if _, err := parseLarkWebhookURL(value); err != nil {
|
||||
return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err)
|
||||
}
|
||||
}
|
||||
case "integer":
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
@@ -387,7 +418,7 @@ func (s *Server) handleNotificationTest(
|
||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||
return
|
||||
}
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusNotImplemented,
|
||||
@@ -441,6 +472,8 @@ func (s *Server) handleNotificationTest(
|
||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||
case "lark":
|
||||
err = sendLarkNotificationTest(notificationContext, resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -546,8 +579,8 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
|
||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||
// settings form while allowing newly entered sensitive values in the same
|
||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
||||
// used by the other notification channels.
|
||||
// request. Provider webhook URLs can be sensitive lists, unlike the
|
||||
// string-based secrets used by the other notification channels.
|
||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
@@ -595,6 +628,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
||||
}
|
||||
case "wecom":
|
||||
return validateWecomNotificationConfig(config)
|
||||
case "lark":
|
||||
return validateLarkNotificationConfig(config)
|
||||
case "telegram":
|
||||
token := configString(config, "bot_token")
|
||||
if token == "" || token == store.SecretMask {
|
||||
@@ -954,7 +989,7 @@ func validateOutboundURL(
|
||||
}
|
||||
|
||||
func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, false)
|
||||
parsed, err := parseProxyURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -964,6 +999,26 @@ func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, er
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseProxyURL parses an HTTP(S) proxy URL. Unlike parseOutboundURL, it
|
||||
// permits embedded userinfo (http://user:pass@host:port) because HTTP proxies
|
||||
// commonly authenticate with Proxy-Authorization derived from the URL.
|
||||
func parseProxyURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||
return nil, errors.New("proxy must be an absolute HTTP URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, errors.New("proxy URL must use HTTP or HTTPS")
|
||||
}
|
||||
if parsed.Port() != "" {
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return nil, errors.New("proxy URL has an invalid port")
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||
|
||||
@@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m
|
||||
return response
|
||||
}
|
||||
|
||||
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
|
||||
func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
||||
if recorder.Code != http.StatusOK {
|
||||
@@ -179,6 +179,73 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||
secret := "lark-signing-secret"
|
||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||
first, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) {
|
||||
t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||
if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask {
|
||||
t.Fatalf("redacted Lark config = %#v", lark)
|
||||
}
|
||||
|
||||
second, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": store.SecretMask, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "lark")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) {
|
||||
t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||
"signing_enabled": false, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||
if _, exists := lark["secret"]; exists {
|
||||
t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
@@ -232,6 +299,45 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored"
|
||||
storedConfig, err := json.Marshal(map[string]any{
|
||||
"url": storedURL,
|
||||
"signing_enabled": true,
|
||||
"secret": "stored-signing-secret",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
||||
Channel: "lark",
|
||||
Config: storedConfig,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"url": store.SecretMask,
|
||||
"signing_enabled": true,
|
||||
"secret": store.SecretMask,
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved["url"] != storedURL {
|
||||
t.Fatalf("resolved URL = %#v", resolved["url"])
|
||||
}
|
||||
if resolved["secret"] != "stored-signing-secret" {
|
||||
t.Fatalf("resolved secret = %#v", resolved["secret"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
cases := []struct {
|
||||
@@ -284,6 +390,41 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "invalid Lark payload template",
|
||||
body: `{"lark":{"enabled":true,"payload_template":"[]"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "enabled Lark config without webhook URL",
|
||||
body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "enabled Lark signing without secret",
|
||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "changed Lark URL with masked signing secret",
|
||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "insecure Lark group bot URL",
|
||||
body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "non-Lark group bot URL",
|
||||
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "webhook URL with embedded credentials",
|
||||
body: `{"webhook":{"enabled":true,"urls":["http://user:[email protected]"]}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "null body",
|
||||
body: `null`,
|
||||
@@ -858,6 +999,41 @@ func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProxyAcceptsAuthenticatedURL(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"http://user:[email protected]:8080"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
data, ok := response["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("data missing: %#v", response)
|
||||
}
|
||||
telegram, ok := data["telegram"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("telegram response missing: %#v", data)
|
||||
}
|
||||
if telegram["proxy"] != "http://user:[email protected]:8080" {
|
||||
t.Fatalf("proxy not preserved: %#v", telegram["proxy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProxyRejectsMalformedURL(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"telegram":{"enabled":true,"bot_token":"123456:abc","chat_id":"1","proxy":"not-a-url"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
detail, ok := response["error"].(map[string]any)
|
||||
if !ok || detail["code"] != "invalid_notification_config" {
|
||||
t.Fatalf("error = %#v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil)
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
const smsNotificationPollInterval = 2 * time.Second
|
||||
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"}
|
||||
|
||||
type smsNotification struct {
|
||||
DeviceID string
|
||||
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
||||
|
||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "bark", "email", "webhook", "wecom":
|
||||
case "bark", "email", "webhook", "wecom", "lark":
|
||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,6 +204,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
||||
return sendWebhookSMSNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkSMSValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,31 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) {
|
||||
message := smsNotification{
|
||||
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||
Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello",
|
||||
}
|
||||
smsValues := larkSMSValues(message)
|
||||
if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" ||
|
||||
smsValues["message"] != message.Text() || smsValues["content"] != "hello" ||
|
||||
smsValues["device_label"] != "EC20" {
|
||||
t.Fatalf("Lark SMS values = %#v", smsValues)
|
||||
}
|
||||
|
||||
taskValues := larkAutomaticTaskValues(automaticTaskNotification{
|
||||
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||
})
|
||||
if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" {
|
||||
t.Fatalf("Lark automatic task values = %#v", taskValues)
|
||||
}
|
||||
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||
if taskValues[name] != "" {
|
||||
t.Fatalf("%s = %q, want empty", name, taskValues[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
valid := map[string]map[string]any{
|
||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||
@@ -76,6 +101,12 @@ func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
},
|
||||
"lark": {
|
||||
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret",
|
||||
"signing_enabled": true,
|
||||
"secret": "signing-secret",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
},
|
||||
}
|
||||
for channel, config := range valid {
|
||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||
|
||||
@@ -1981,7 +1981,7 @@ func (bot *telegramBot) handleATCommand(ctx context.Context, config telegramRunt
|
||||
|
||||
func (bot *telegramBot) executeATCommand(ctx context.Context, deviceID, command string) (string, error) {
|
||||
command = strings.TrimSpace(command)
|
||||
if err := validateATCommand(command); err != nil {
|
||||
if err := validateATCommand(command, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _, physicalID, err := bot.device(deviceID)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
)
|
||||
|
||||
// ussdSessionStore is the HTTP-layer counterpart of device.Manager's USSD
|
||||
// session map. A USSI awaiting-input reply opens a token here so the existing
|
||||
// continue/cancel endpoints keep working; the token only records which device
|
||||
// the dialog belongs to — the IMS session owns the actual network dialog.
|
||||
type ussdSessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]ussdServerSession
|
||||
}
|
||||
|
||||
type ussdServerSession struct {
|
||||
deviceID string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func newUSSDSessionStore() ussdSessionStore {
|
||||
return ussdSessionStore{sessions: make(map[string]ussdServerSession)}
|
||||
}
|
||||
|
||||
func (store *ussdSessionStore) open(deviceID string) string {
|
||||
var token [8]byte
|
||||
_, _ = rand.Read(token[:])
|
||||
id := hex.EncodeToString(token[:])
|
||||
store.mu.Lock()
|
||||
if store.sessions == nil {
|
||||
store.sessions = make(map[string]ussdServerSession)
|
||||
}
|
||||
store.sessions[id] = ussdServerSession{deviceID: deviceID, createdAt: time.Now().UTC()}
|
||||
store.mu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func (store *ussdSessionStore) device(sessionID string) (string, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
session, ok := store.sessions[strings.TrimSpace(sessionID)]
|
||||
if !ok {
|
||||
return "", device.ErrUSSDSessionNotFound
|
||||
}
|
||||
return session.deviceID, nil
|
||||
}
|
||||
|
||||
func (store *ussdSessionStore) drop(sessionID string) {
|
||||
store.mu.Lock()
|
||||
delete(store.sessions, strings.TrimSpace(sessionID))
|
||||
store.mu.Unlock()
|
||||
}
|
||||
@@ -917,6 +917,56 @@ func TestNotificationArraySecretPreservation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "lark", Enabled: true,
|
||||
Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err := database.NotificationSetting(ctx, "lark")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redacted["url"] != SecretMask || redacted["secret"] != SecretMask {
|
||||
t.Fatalf("redacted Lark config = %#v", redacted)
|
||||
}
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "lark", Enabled: true,
|
||||
Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err = database.NotificationSetting(ctx, "lark")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) {
|
||||
t.Fatalf("stored Lark config = %s", setting.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) {
|
||||
setting := NotificationSetting{
|
||||
Config: json.RawMessage(`{"url":"","secret":""}`),
|
||||
SensitiveFields: []string{"url", "secret"},
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redacted["url"] != "" || redacted["secret"] != "" {
|
||||
t.Fatalf("empty sensitive values were masked: %#v", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
|
||||
@@ -610,6 +610,9 @@ func mergeJSONSecrets(
|
||||
func redactJSONValue(value any, replacement string) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed == "" {
|
||||
return ""
|
||||
}
|
||||
return replacement
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
|
||||
@@ -24,6 +24,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
||||
return []string{"token"}
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
case "lark":
|
||||
return []string{"url", "secret"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLongSMSReassemblySurvivesServiceRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
const (
|
||||
deviceID = "dajiang"
|
||||
imei = "867394042309830"
|
||||
peer = "+447700900123"
|
||||
)
|
||||
messageID := StableConcatMessageID("ims", imei, deviceID, peer, 27, 2)
|
||||
|
||||
database, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustSaveDevice(t, database, deviceID, "大疆")
|
||||
first, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第一段:安全提醒,",
|
||||
Timestamp: time.Unix(1_700_000_000, 0).UTC(), Status: "received", Source: "ims",
|
||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
|
||||
t.Fatal("partial message must not be ready before restart")
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err = Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
second, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||
Timestamp: time.Unix(1_700_000_030, 0).UTC(), Status: "received", Source: "ims",
|
||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Body != "第一段:安全提醒,第二段:请通过官方渠道核实。" ||
|
||||
!ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
|
||||
t.Fatalf("reassembled message after restart = %#v", second)
|
||||
}
|
||||
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: deviceID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].ID != second.ID {
|
||||
t.Fatalf("stored messages after restart = %#v, want one merged row", messages)
|
||||
}
|
||||
redelivered, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||
Status: "received", Source: "ims", PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redelivered.ID != second.ID || redelivered.Body != second.Body {
|
||||
t.Fatalf("redelivery duplicated or changed message: %#v", redelivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartDeliveryReportsSurviveServiceRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
database, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustSaveDevice(t, database, "dajiang", "大疆")
|
||||
extra := json.RawMessage(`{"transport":"ims","part_results":[{"reference":51},{"reference":52}]}`)
|
||||
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: "ims-submit-restart", DeviceID: "dajiang", IMSI: "23433",
|
||||
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
|
||||
Status: "accepted_by_ims", Source: "ims", PartsTotal: 2,
|
||||
DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err = Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||
MessageReference: 51, StatusCode: 0, DeliveryState: "delivered",
|
||||
})
|
||||
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
|
||||
t.Fatalf("first report after restart = (%#v, %v)", first, err)
|
||||
}
|
||||
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||
MessageReference: 52, StatusCode: 0, DeliveryState: "delivered",
|
||||
})
|
||||
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
|
||||
t.Fatalf("second report after restart = (%#v, %v)", second, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectSystemdUnitUsesExplicitOverride(t *testing.T) {
|
||||
t.Setenv("VOCAT_SYSTEMD_UNIT", "vocat-test.service")
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if got := detectSystemdUnit(logger); got != "vocat-test.service" {
|
||||
t.Fatalf("detectSystemdUnit() = %q, want vocat-test.service", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdUnitFromCgroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
}{
|
||||
{name: "cgroup v2", data: "0::/system.slice/vocat-test.service\n", want: "vocat-test.service"},
|
||||
{name: "legacy", data: "1:name=systemd:/system.slice/[email protected]\n", want: "[email protected]"},
|
||||
{name: "no service", data: "0::/user.slice/user-1000.slice/session-1.scope\n", want: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := systemdUnitFromCgroup(test.data); got != test.want {
|
||||
t.Fatalf("systemdUnitFromCgroup() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSystemdUnitRejectsArgumentsAndPaths(t *testing.T) {
|
||||
for _, value := range []string{"vocat", "../vocat.service", "vocat.service --now", "vocat.service/other"} {
|
||||
if validSystemdUnit.MatchString(value) {
|
||||
t.Fatalf("validSystemdUnit unexpectedly accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -320,17 +322,63 @@ func RestartService(logger *slog.Logger) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||
}
|
||||
unit := detectSystemdUnit(logger)
|
||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||
return fmt.Errorf("systemctl restart vocat: %w", err)
|
||||
return fmt.Errorf("systemctl restart %s: %w", unit, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var validSystemdUnit = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`)
|
||||
|
||||
func detectSystemdUnit(logger *slog.Logger) string {
|
||||
if configured := strings.TrimSpace(os.Getenv("VOCAT_SYSTEMD_UNIT")); validSystemdUnit.MatchString(configured) {
|
||||
return configured
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||
if unit := systemdUnitFromCgroup(string(data)); unit != "" {
|
||||
return unit
|
||||
}
|
||||
}
|
||||
// Some cgroup namespaces hide the unit name. Query loaded services and
|
||||
// identify the unit whose MainPID is this process before falling back.
|
||||
list := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain")
|
||||
if output, err := list.Output(); err == nil {
|
||||
pid := strconv.Itoa(os.Getpid())
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 || !validSystemdUnit.MatchString(fields[0]) {
|
||||
continue
|
||||
}
|
||||
show := exec.Command("systemctl", "show", fields[0], "--property=MainPID", "--value")
|
||||
if value, showErr := show.Output(); showErr == nil && strings.TrimSpace(string(value)) == pid {
|
||||
return fields[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Warn("could not identify the current systemd unit; using vocat.service", "hint", "set VOCAT_SYSTEMD_UNIT for a custom unit")
|
||||
}
|
||||
return "vocat.service"
|
||||
}
|
||||
|
||||
func systemdUnitFromCgroup(data string) string {
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
for _, part := range strings.Split(line, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if validSystemdUnit.MatchString(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveDefaultTarget returns the conventional install path when present,
|
||||
// falling back to the running executable. This lets `vocat update` "just work"
|
||||
// on the standard systemd host without flags.
|
||||
|
||||
@@ -3,17 +3,24 @@ package vowifi
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
CarrierProfileStandard = "standard-3gpp"
|
||||
IKEProposalModern = "modern"
|
||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||
IMSProfileStandard = "standard"
|
||||
IMSProfileO2Germany = "o2-germany"
|
||||
IMSProfileATT = "att"
|
||||
CarrierProfileSchemaVersion = 1
|
||||
CarrierProfileStandard = "standard-3gpp"
|
||||
IKEProposalModern = "modern"
|
||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||
IMSProfileStandard = "standard"
|
||||
IMSProfileO2Germany = "o2-germany"
|
||||
IMSProfileATT = "att"
|
||||
)
|
||||
|
||||
// CarrierProfile contains only interoperability choices that cannot be
|
||||
@@ -21,65 +28,115 @@ const (
|
||||
// protocol layers consume this common result so their carrier handling cannot
|
||||
// drift into separate MCC/MNC switch statements.
|
||||
type CarrierProfile struct {
|
||||
ID string
|
||||
MatchSource string
|
||||
RouteMCC string
|
||||
RouteMNC string
|
||||
EPDG string
|
||||
IKEProposal string
|
||||
AdvertiseEAPOnly bool
|
||||
IMSTransport string
|
||||
IMSIdentityProfile string
|
||||
IMSRegisterProfile string
|
||||
IMSIPSecEncryption string
|
||||
SMSCenter string
|
||||
ID string
|
||||
MatchSource string
|
||||
RouteMCC string
|
||||
RouteMNC string
|
||||
EPDG string
|
||||
IKEProposal string
|
||||
AdvertiseEAPOnly bool
|
||||
AllowSMSWithoutContactConfirmation bool
|
||||
IMSRegisterOptions IMSRegisterOptions
|
||||
IMSTransport string
|
||||
IMSIdentityProfile string
|
||||
IMSRegisterProfile string
|
||||
IMSIPSecEncryption string
|
||||
SMSCenter string
|
||||
PANICountry string
|
||||
PANINode string
|
||||
IMSDialURIScheme string
|
||||
IMSUserEqPhone bool
|
||||
IMSVoiceCodecs []string
|
||||
}
|
||||
|
||||
// IMSRegisterOptions carries carrier-specific SIP REGISTER header values.
|
||||
// Pointer fields distinguish "use default" (nil) from "explicitly omit" ("").
|
||||
type IMSRegisterOptions struct {
|
||||
ContactFormat string
|
||||
ExpirySeconds int
|
||||
ContactExtraTags []string
|
||||
SupportedHeader *string
|
||||
AllowHeader *string
|
||||
UserAgent string
|
||||
PPreferredIdentity bool
|
||||
PVisitedNetworkID string
|
||||
PAccessNetworkInfo *string
|
||||
CellularNetworkInfo string
|
||||
AcceptContactTags []string
|
||||
}
|
||||
|
||||
const (
|
||||
IMSContactFormatStandard = "standard"
|
||||
IMSContactFormatATT = "att"
|
||||
)
|
||||
|
||||
type carrierProfileDocument struct {
|
||||
Version int `json:"version"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
}
|
||||
|
||||
type carrierProfileRule struct {
|
||||
ID string `json:"id"`
|
||||
Match carrierProfileMatch `json:"match"`
|
||||
Route carrierProfileRoute `json:"route"`
|
||||
EPDG carrierProfileEPDG `json:"epdg"`
|
||||
IKE carrierProfileIKE `json:"ike"`
|
||||
IMS carrierProfileIMS `json:"ims"`
|
||||
ID string `json:"id"`
|
||||
Match carrierProfileMatch `json:"match,omitzero"`
|
||||
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
||||
Route carrierProfileRoute `json:"route,omitzero"`
|
||||
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
||||
IKE carrierProfileIKE `json:"ike,omitzero"`
|
||||
IMS carrierProfileIMS `json:"ims,omitzero"`
|
||||
}
|
||||
|
||||
type carrierProfileMatch struct {
|
||||
HomePLMNs []string `json:"home_plmns"`
|
||||
IMSIPrefixes []string `json:"imsi_prefixes"`
|
||||
ICCIDPrefixes []string `json:"iccid_prefixes"`
|
||||
SPNs []string `json:"spns"`
|
||||
GID1Prefixes []string `json:"gid1_prefixes"`
|
||||
GID2Prefixes []string `json:"gid2_prefixes"`
|
||||
HomePLMNs []string `json:"home_plmns,omitempty"`
|
||||
IMSIPrefixes []string `json:"imsi_prefixes,omitempty"`
|
||||
ICCIDPrefixes []string `json:"iccid_prefixes,omitempty"`
|
||||
SPNs []string `json:"spns,omitempty"`
|
||||
GID1Prefixes []string `json:"gid1_prefixes,omitempty"`
|
||||
GID2Prefixes []string `json:"gid2_prefixes,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileRoute struct {
|
||||
MCC string `json:"mcc"`
|
||||
MNC string `json:"mnc"`
|
||||
MCC string `json:"mcc,omitempty"`
|
||||
MNC string `json:"mnc,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileEPDG struct {
|
||||
Hostname string `json:"hostname"`
|
||||
DNSHosts []string `json:"dns_hosts"`
|
||||
DNSClientSubnet string `json:"dns_client_subnet"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
DNSHosts []string `json:"dns_hosts,omitempty"`
|
||||
DNSClientSubnet string `json:"dns_client_subnet,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileIKE struct {
|
||||
Proposal string `json:"proposal"`
|
||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only"`
|
||||
Proposal string `json:"proposal,omitempty"`
|
||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileIMS struct {
|
||||
Transport string `json:"transport"`
|
||||
IdentityProfile string `json:"identity_profile"`
|
||||
RegisterProfile string `json:"register_profile"`
|
||||
IPSecEncryption string `json:"ipsec_encryption"`
|
||||
SMSCenter string `json:"sms_center"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
IdentityProfile string `json:"identity_profile,omitempty"`
|
||||
RegisterProfile string `json:"register_profile,omitempty"`
|
||||
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
||||
SMSCenter string `json:"sms_center,omitempty"`
|
||||
PANICountry string `json:"pani_country,omitempty"`
|
||||
PANINode string `json:"pani_node,omitempty"`
|
||||
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
||||
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
||||
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
||||
RegisterOptions carrierProfileRegisterOptions `json:"register_options,omitzero"`
|
||||
AllowSMSWithoutContactConfirmation *bool `json:"allow_sms_without_contact_confirmation,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileRegisterOptions struct {
|
||||
ContactFormat string `json:"contact_format,omitempty"`
|
||||
ExpirySeconds int `json:"expiry_seconds,omitempty"`
|
||||
ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
|
||||
SupportedHeader *string `json:"supported_header,omitempty"`
|
||||
AllowHeader *string `json:"allow_header,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
|
||||
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
|
||||
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
|
||||
CellularNetworkInfo string `json:"cellular_network_info,omitempty"`
|
||||
AcceptContactTags []string `json:"accept_contact_tags,omitempty"`
|
||||
}
|
||||
|
||||
//go:embed carrier_profiles.json
|
||||
@@ -87,42 +144,162 @@ var carrierProfilesJSON []byte
|
||||
|
||||
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||
|
||||
var externalCarrierProfiles = struct {
|
||||
sync.RWMutex
|
||||
rules []carrierProfileRule
|
||||
}{}
|
||||
|
||||
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||
rules, err := loadCarrierProfiles(encoded)
|
||||
if err != nil {
|
||||
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||
}
|
||||
if document.Version != 1 {
|
||||
panic(fmt.Sprintf("vowifi: unsupported carrier profile version %d", document.Version))
|
||||
return rules
|
||||
}
|
||||
|
||||
func loadCarrierProfiles(encoded []byte) ([]carrierProfileRule, error) {
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if document.Version != CarrierProfileSchemaVersion {
|
||||
return nil, fmt.Errorf("unsupported carrier profile version %d", document.Version)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(document.Profiles))
|
||||
for index := range document.Profiles {
|
||||
rule := &document.Profiles[index]
|
||||
rule.ID = strings.TrimSpace(rule.ID)
|
||||
if rule.ID == "" {
|
||||
panic("vowifi: carrier profile ID is empty")
|
||||
return nil, fmt.Errorf("carrier profile %d ID is empty", index)
|
||||
}
|
||||
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{}{}
|
||||
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 {
|
||||
match := rule.Match
|
||||
if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 {
|
||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
matches = append(matches, rule.MatchAny...)
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, plmn := range match.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == "" {
|
||||
for _, match := range matches {
|
||||
if emptyCarrierProfileMatch(match) {
|
||||
return false
|
||||
}
|
||||
for _, plmn := range match.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range match.IMSIPrefixes {
|
||||
if len(prefix) < 5 || len(prefix) > 18 || !decimalString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range match.ICCIDPrefixes {
|
||||
if len(prefix) < 5 || len(prefix) > 22 || !decimalString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range append(append([]string(nil), match.GID1Prefixes...), match.GID2Prefixes...) {
|
||||
if len(prefix) < 1 || len(prefix) > 64 || !hexString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, spn := range match.SPNs {
|
||||
if strings.TrimSpace(spn) == "" || len(spn) > 128 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
||||
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
||||
@@ -140,6 +317,75 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
encryption != "aes-cbc" && encryption != "null" {
|
||||
return false
|
||||
}
|
||||
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
||||
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
||||
return false
|
||||
}
|
||||
if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" {
|
||||
return false
|
||||
}
|
||||
for _, codec := range rule.IMS.VoiceCodecs {
|
||||
switch strings.ToUpper(strings.TrimSpace(codec)) {
|
||||
case "PCMA", "PCMU", "AMR", "AMR-WB":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if rule.IMS.RegisterOptions.ExpirySeconds != 0 &&
|
||||
(rule.IMS.RegisterOptions.ExpirySeconds < 60 || rule.IMS.RegisterOptions.ExpirySeconds > 86400) {
|
||||
return false
|
||||
}
|
||||
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
|
||||
format != IMSContactFormatStandard && format != IMSContactFormatATT {
|
||||
return false
|
||||
}
|
||||
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, value := range []*string{rule.IMS.RegisterOptions.SupportedHeader, rule.IMS.RegisterOptions.AllowHeader, rule.IMS.RegisterOptions.PAccessNetworkInfo} {
|
||||
if value != nil && strings.ContainsAny(*value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, value := range rule.IMS.RegisterOptions.AcceptContactTags {
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func emptyCarrierProfileMatch(match carrierProfileMatch) bool {
|
||||
return len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0
|
||||
}
|
||||
|
||||
func hexString(value string) bool {
|
||||
for _, item := range value {
|
||||
if item >= '0' && item <= '9' || item >= 'a' && item <= 'f' || item >= 'A' && item <= 'F' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func decimalString(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range value {
|
||||
if item < '0' || item > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -155,11 +401,13 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
IMSIdentityProfile: IMSProfileStandard,
|
||||
IMSRegisterProfile: IMSProfileStandard,
|
||||
IMSIPSecEncryption: "aes-cbc",
|
||||
IMSDialURIScheme: "tel",
|
||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||
}
|
||||
bestScore := -1
|
||||
for _, rule := range builtinCarrierProfiles {
|
||||
score, source, matched := matchCarrierProfile(rule.Match, identity)
|
||||
if !matched || score <= bestScore {
|
||||
for _, rule := range carrierProfilesSnapshot() {
|
||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||
if !matched || score < bestScore {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
@@ -168,6 +416,28 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
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) {
|
||||
score := 0
|
||||
sources := make([]string, 0, 6)
|
||||
@@ -256,9 +526,81 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
|
||||
base.IMSIPSecEncryption = value
|
||||
}
|
||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
||||
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
||||
base.IMSDialURIScheme = value
|
||||
}
|
||||
if rule.IMS.UserEqPhone != nil {
|
||||
base.IMSUserEqPhone = *rule.IMS.UserEqPhone
|
||||
}
|
||||
if len(rule.IMS.VoiceCodecs) > 0 {
|
||||
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
||||
}
|
||||
if rule.IMS.AllowSMSWithoutContactConfirmation != nil {
|
||||
base.AllowSMSWithoutContactConfirmation = *rule.IMS.AllowSMSWithoutContactConfirmation
|
||||
}
|
||||
base.IMSRegisterOptions = applyRegisterOptions(base.IMSRegisterOptions, rule.IMS.RegisterOptions)
|
||||
return base
|
||||
}
|
||||
|
||||
func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOptions) IMSRegisterOptions {
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.ContactFormat)); value != "" {
|
||||
base.ContactFormat = value
|
||||
}
|
||||
if rule.ExpirySeconds != 0 {
|
||||
base.ExpirySeconds = rule.ExpirySeconds
|
||||
}
|
||||
if len(rule.ContactExtraTags) > 0 {
|
||||
base.ContactExtraTags = append([]string(nil), rule.ContactExtraTags...)
|
||||
}
|
||||
if rule.SupportedHeader != nil {
|
||||
value := strings.TrimSpace(*rule.SupportedHeader)
|
||||
base.SupportedHeader = &value
|
||||
}
|
||||
if rule.AllowHeader != nil {
|
||||
value := strings.TrimSpace(*rule.AllowHeader)
|
||||
base.AllowHeader = &value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.UserAgent); value != "" {
|
||||
base.UserAgent = value
|
||||
}
|
||||
if rule.PPreferredIdentity {
|
||||
base.PPreferredIdentity = true
|
||||
}
|
||||
if value := strings.TrimSpace(rule.PVisitedNetworkID); value != "" {
|
||||
base.PVisitedNetworkID = value
|
||||
}
|
||||
if rule.PAccessNetworkInfo != nil {
|
||||
value := strings.TrimSpace(*rule.PAccessNetworkInfo)
|
||||
base.PAccessNetworkInfo = &value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.CellularNetworkInfo); value != "" {
|
||||
base.CellularNetworkInfo = value
|
||||
}
|
||||
if len(rule.AcceptContactTags) > 0 {
|
||||
base.AcceptContactTags = append([]string(nil), rule.AcceptContactTags...)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func normalizeVoiceCodecs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canonicalPLMN(mcc, mnc string) string {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
@@ -323,7 +665,7 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||
func EPDGDNSClientSubnet(host string) string {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
for _, rule := range builtinCarrierProfiles {
|
||||
for _, rule := range carrierProfilesSnapshot() {
|
||||
for _, candidate := range rule.EPDG.DNSHosts {
|
||||
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
||||
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package vowifi
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -81,6 +84,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) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
||||
@@ -98,3 +148,76 @@ func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if !profile.AllowSMSWithoutContactConfirmation {
|
||||
t.Fatalf("DITO profile should allow SMS without contact confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileATTRegisterOptions(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"})
|
||||
if profile.ID != "att-us" {
|
||||
t.Fatalf("AT&T profile = %#v", profile)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ContactFormat != IMSContactFormatATT {
|
||||
t.Fatalf("AT&T contact format = %q, want %q", profile.IMSRegisterOptions.ContactFormat, IMSContactFormatATT)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ExpirySeconds != 18400 {
|
||||
t.Fatalf("AT&T expiry = %d, want 18400", profile.IMSRegisterOptions.ExpirySeconds)
|
||||
}
|
||||
if profile.IMSRegisterOptions.UserAgent != "SimAdmin VoWiFi" {
|
||||
t.Fatalf("AT&T user agent = %q", profile.IMSRegisterOptions.UserAgent)
|
||||
}
|
||||
if profile.IMSRegisterOptions.PVisitedNetworkID != "one.att.net" {
|
||||
t.Fatalf("AT&T P-Visited-Network-ID = %q", profile.IMSRegisterOptions.PVisitedNetworkID)
|
||||
}
|
||||
if len(profile.IMSRegisterOptions.AcceptContactTags) != 2 {
|
||||
t.Fatalf("AT&T Accept-Contact tags = %v", profile.IMSRegisterOptions.AcceptContactTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileO2GermanyRegisterOptions(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: "03"})
|
||||
if profile.ID != "o2-germany" {
|
||||
t.Fatalf("O2 Germany profile = %#v", profile)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ContactFormat != "" {
|
||||
t.Fatalf("O2 Germany contact format = %q, want empty", profile.IMSRegisterOptions.ContactFormat)
|
||||
}
|
||||
if profile.IMSRegisterOptions.SupportedHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.SupportedHeader, "sec-agree") {
|
||||
t.Fatalf("O2 Germany Supported header = %v", profile.IMSRegisterOptions.SupportedHeader)
|
||||
}
|
||||
if profile.IMSRegisterOptions.AllowHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.AllowHeader, "MESSAGE") {
|
||||
t.Fatalf("O2 Germany Allow header = %v", profile.IMSRegisterOptions.AllowHeader)
|
||||
}
|
||||
if !profile.IMSRegisterOptions.PPreferredIdentity {
|
||||
t.Fatal("O2 Germany should add P-Preferred-Identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "001", HomeMNC: "01"})
|
||||
if profile.ID != CarrierProfileStandard {
|
||||
t.Fatalf("profile = %q", profile.ID)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ExpirySeconds != 0 {
|
||||
t.Fatalf("standard expiry = %d", profile.IMSRegisterOptions.ExpirySeconds)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ContactFormat != "" {
|
||||
t.Fatalf("standard contact format = %q", profile.IMSRegisterOptions.ContactFormat)
|
||||
}
|
||||
if profile.IMSRegisterOptions.SupportedHeader != nil {
|
||||
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
|
||||
}
|
||||
if profile.AllowSMSWithoutContactConfirmation {
|
||||
t.Fatal("standard profile should require SMS contact confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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" }
|
||||
},
|
||||
{
|
||||
"id": "ctexcel-ee-uk",
|
||||
"id": "ee-uk-hosted-23433",
|
||||
"match": {
|
||||
"home_plmns": ["23433"],
|
||||
"imsi_prefixes": ["23433"],
|
||||
"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",
|
||||
@@ -30,7 +36,22 @@
|
||||
"ims": {
|
||||
"identity_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 },
|
||||
"ims": {
|
||||
"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"] },
|
||||
"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",
|
||||
"match": { "home_plmns": ["23410"] },
|
||||
"ims": {
|
||||
"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_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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -188,34 +188,60 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
makeNotify(notifyNATSource, sourceHash),
|
||||
makeNotify(notifyNATDestination, destinationHash),
|
||||
}
|
||||
first, initBody, err := marshalPayloadChain(initPayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initRequest := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}.marshal(initBody)
|
||||
initResponse, err := transport.RoundTrip(ctx, initRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseHeader, responseBody, err := validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if responseHeader.ResponderSPI == [8]byte{} {
|
||||
return nil, errors.New("ike: responder returned a zero SPI")
|
||||
}
|
||||
initResponsePayloads, err := parsePayloadChain(responseHeader.NextPayload, responseBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
|
||||
return nil, err
|
||||
var (
|
||||
initRequest []byte
|
||||
initResponse []byte
|
||||
responseHeader ikeHeader
|
||||
initResponsePayloads []payload
|
||||
cookie []byte
|
||||
)
|
||||
for attempt := 0; attempt < maxIKEInitCookieChallenges; attempt++ {
|
||||
requestPayloads := append([]payload(nil), initPayloads...)
|
||||
if len(cookie) > 0 {
|
||||
requestPayloads = append([]payload{makeNotify(notifyCookie, cookie)}, requestPayloads...)
|
||||
}
|
||||
first, initBody, err := marshalPayloadChain(requestPayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initRequest = ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}.marshal(initBody)
|
||||
initResponse, err = transport.RoundTrip(ctx, initRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var responseBody []byte
|
||||
responseHeader, responseBody, err = validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initResponsePayloads, err = parsePayloadChain(responseHeader.NextPayload, responseBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectFatalNotifications(initResponsePayloads); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challenge, hasCookie, err := ikeInitCookie(initResponsePayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCookie {
|
||||
if attempt+1 == maxIKEInitCookieChallenges {
|
||||
return nil, errors.New("ike: ePDG requested too many COOKIE challenges")
|
||||
}
|
||||
cookie = challenge
|
||||
continue
|
||||
}
|
||||
if responseHeader.ResponderSPI == [8]byte{} {
|
||||
return nil, errors.New("ike: responder returned a zero SPI")
|
||||
}
|
||||
break
|
||||
}
|
||||
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
||||
if err != nil {
|
||||
@@ -616,6 +642,29 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
||||
return session, nil
|
||||
}
|
||||
|
||||
const maxIKEInitCookieChallenges = 2
|
||||
|
||||
func ikeInitCookie(payloads []payload) ([]byte, bool, error) {
|
||||
var cookie []byte
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if kind != notifyCookie {
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, false, errors.New("ike: ePDG returned an empty COOKIE")
|
||||
}
|
||||
if cookie != nil {
|
||||
return nil, false, errors.New("ike: ePDG returned multiple COOKIE notifications")
|
||||
}
|
||||
cookie = append([]byte(nil), data...)
|
||||
}
|
||||
return cookie, cookie != nil, nil
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -64,17 +66,20 @@ func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
}
|
||||
|
||||
type firstAuthCaptureTransport struct {
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
t *testing.T
|
||||
wantEAPOnly bool
|
||||
wantGroup uint16
|
||||
calls int
|
||||
suite negotiatedSuite
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
nonceI []byte
|
||||
nonceR []byte
|
||||
floated bool
|
||||
cookieChallenge []byte
|
||||
cookieSeen bool
|
||||
cookieLoop bool
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
|
||||
@@ -92,6 +97,24 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
|
||||
|
||||
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
|
||||
transport.calls++
|
||||
if len(transport.cookieChallenge) > 0 {
|
||||
switch transport.calls {
|
||||
case 1:
|
||||
return transport.answerIKECookie(packet)
|
||||
case 2:
|
||||
if err := transport.verifyIKECookie(packet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if transport.cookieLoop {
|
||||
return transport.answerIKECookie(packet)
|
||||
}
|
||||
return transport.answerIKEInit(packet)
|
||||
case 3:
|
||||
return nil, transport.observeFirstAuth(packet)
|
||||
default:
|
||||
return nil, errors.New("test: unexpected exchange")
|
||||
}
|
||||
}
|
||||
switch transport.calls {
|
||||
case 1:
|
||||
return transport.answerIKEInit(packet)
|
||||
@@ -102,6 +125,69 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
||||
header, _, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first, body, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, transport.cookieChallenge),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ikeHeader{
|
||||
InitiatorSPI: header.InitiatorSPI,
|
||||
NextPayload: first,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
MessageID: 0,
|
||||
}.marshal(body), nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) verifyIKECookie(packet []byte) error {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if header.Exchange != exchangeIKEInit || header.MessageID != 0 || header.Flags != flagInitiator {
|
||||
return errors.New("test: invalid retried IKE_SA_INIT header")
|
||||
}
|
||||
payloads, err := parsePayloadChain(header.NextPayload, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payloads) == 0 || payloads[0].Type != payloadNotify {
|
||||
return errors.New("test: retried IKE_SA_INIT did not put COOKIE first")
|
||||
}
|
||||
firstKind, firstData, err := parseNotify(payloads[0])
|
||||
if err != nil || firstKind != notifyCookie || !bytes.Equal(firstData, transport.cookieChallenge) {
|
||||
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
||||
}
|
||||
cookies := payloadsOfType(payloads, payloadNotify)
|
||||
if len(cookies) != 3 {
|
||||
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", len(cookies))
|
||||
}
|
||||
found := false
|
||||
for _, item := range cookies {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == notifyCookie {
|
||||
if !bytes.Equal(data, transport.cookieChallenge) {
|
||||
return fmt.Errorf("test: cookie = %x, want %x", data, transport.cookieChallenge)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("test: retried IKE_SA_INIT did not carry COOKIE")
|
||||
}
|
||||
transport.cookieSeen = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte, error) {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
@@ -291,6 +377,93 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRetriesIKEInitAfterCookie(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{
|
||||
t: t,
|
||||
wantEAPOnly: true,
|
||||
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
|
||||
}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-cookie",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8944100000000000000",
|
||||
IMSI: "234150123456789",
|
||||
HomeMCC: "234",
|
||||
HomeMNC: "15",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if !errors.Is(err, errFirstAuthObserved) {
|
||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
||||
}
|
||||
if capture.calls != 3 || !capture.cookieSeen || capture.floated || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d cookie_seen=%v floated=%v AKA calls=%d", capture.calls, capture.cookieSeen, capture.floated, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderBoundsRepeatedIKEInitCookieChallenges(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{
|
||||
t: t,
|
||||
wantEAPOnly: true,
|
||||
cookieChallenge: []byte{0x10, 0x20, 0x30, 0x40},
|
||||
cookieLoop: true,
|
||||
}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-cookie-loop",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8944100000000000000",
|
||||
IMSI: "234150123456789",
|
||||
HomeMCC: "234",
|
||||
HomeMNC: "15",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "too many COOKIE challenges") {
|
||||
t.Fatalf("Start() error = %v, want bounded COOKIE error", err)
|
||||
}
|
||||
if capture.calls != maxIKEInitCookieChallenges || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d AKA calls=%d", capture.calls, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
||||
provider, err := NewProvider(Config{
|
||||
|
||||
@@ -576,10 +576,17 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
||||
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
||||
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
||||
var lastErr error
|
||||
var cookieResponse []byte
|
||||
for _, candidate := range transport.remotes {
|
||||
transport.remote = cloneUDPAddr(candidate)
|
||||
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
||||
if attemptErr == nil {
|
||||
if ikeInitResponseHasCookie(response) {
|
||||
if cookieResponse == nil {
|
||||
cookieResponse = append([]byte(nil), response...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
lastErr = attemptErr
|
||||
@@ -587,6 +594,9 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
||||
return nil, attemptErr
|
||||
}
|
||||
}
|
||||
if cookieResponse != nil {
|
||||
return cookieResponse, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
||||
}
|
||||
return transport.roundTripLocked(ctx, packet, requestHeader)
|
||||
@@ -824,11 +834,34 @@ func ikeResponseMatchesRequest(
|
||||
}
|
||||
var zeroSPI [8]byte
|
||||
if request.ResponderSPI == zeroSPI {
|
||||
return response.ResponderSPI != zeroSPI
|
||||
if response.ResponderSPI != zeroSPI {
|
||||
return true
|
||||
}
|
||||
return response.Exchange == exchangeIKEInit &&
|
||||
response.MessageID == 0 &&
|
||||
ikeInitResponseHasCookie(packet)
|
||||
}
|
||||
return response.ResponderSPI == request.ResponderSPI
|
||||
}
|
||||
|
||||
func ikeInitResponseHasCookie(packet []byte) bool {
|
||||
header, body, err := parseIKEPacket(packet)
|
||||
if err != nil || header.Exchange != exchangeIKEInit || header.MessageID != 0 {
|
||||
return false
|
||||
}
|
||||
payloads, err := parsePayloadChain(header.NextPayload, body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
if err == nil && kind == notifyCookie && len(data) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func marshalSOCKS5Datagram(remote *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||
if remote == nil || remote.IP == nil || remote.Port < 1 || remote.Port > 65535 {
|
||||
return nil, errors.New("ike: invalid SOCKS5 UDP destination")
|
||||
|
||||
@@ -145,6 +145,18 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
}.marshal([]byte("response"))
|
||||
cookieFirst, cookieBody, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookieResponse := ikeHeader{
|
||||
InitiatorSPI: requestHeader.InitiatorSPI,
|
||||
NextPayload: cookieFirst,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
}.marshal(cookieBody)
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 2048)
|
||||
@@ -160,6 +172,14 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
return
|
||||
}
|
||||
if !destination.IP.Equal(second.IP) {
|
||||
cookieWire, marshalErr := marshalSOCKS5Datagram(first, cookieResponse)
|
||||
if marshalErr == nil {
|
||||
_, marshalErr = relay.WriteToUDP(cookieWire, peer)
|
||||
}
|
||||
if marshalErr != nil {
|
||||
serverDone <- marshalErr
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
wire, marshalErr := marshalSOCKS5Datagram(second, response)
|
||||
@@ -186,6 +206,31 @@ func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIKEResponseMatchesCookieChallengeWithZeroResponderSPI(t *testing.T) {
|
||||
request := ikeHeader{
|
||||
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagInitiator,
|
||||
MessageID: 0,
|
||||
}
|
||||
first, body, err := marshalPayloadChain([]payload{
|
||||
makeNotify(notifyCookie, []byte{0x10, 0x20, 0x30, 0x40}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := ikeHeader{
|
||||
InitiatorSPI: request.InitiatorSPI,
|
||||
Exchange: exchangeIKEInit,
|
||||
Flags: flagResponse,
|
||||
MessageID: 0,
|
||||
NextPayload: first,
|
||||
}.marshal(body)
|
||||
if !ikeResponseMatchesRequest(response, request) {
|
||||
t.Fatal("IKE COOKIE response with zero Responder SPI was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKS5RoundTripSkipsStaleAndESPDatagrams(t *testing.T) {
|
||||
relay, err := net.ListenUDP(
|
||||
"udp",
|
||||
|
||||
@@ -59,6 +59,7 @@ const (
|
||||
notifyMOBIKESupported = 16396
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyCookie = 16390
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyDeviceIdentity = 41101
|
||||
notifyInvalidKE = 17
|
||||
|
||||
@@ -20,21 +20,30 @@ var (
|
||||
|
||||
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 {
|
||||
public vowifi.Call
|
||||
callID string
|
||||
target string
|
||||
from string
|
||||
to string
|
||||
branch string
|
||||
cseq uint32
|
||||
invite *sipRequest
|
||||
respond func([]byte) error
|
||||
responses chan *sipResponse
|
||||
remoteTag string
|
||||
routes []string
|
||||
terminated bool
|
||||
media *rtpMedia
|
||||
public vowifi.Call
|
||||
callID string
|
||||
target string
|
||||
from string
|
||||
to string
|
||||
branch string
|
||||
cseq uint32
|
||||
inviteTarget string
|
||||
invite *sipRequest
|
||||
respond func([]byte) error
|
||||
responses chan *sipResponse
|
||||
remoteTag string
|
||||
routes []string
|
||||
terminated bool
|
||||
media *rtpMedia
|
||||
pracked map[string]bool
|
||||
sessionExpires int
|
||||
sessionCancel context.CancelFunc
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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()
|
||||
cseq := session.cseq
|
||||
session.cseq++
|
||||
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
fromIdentity, preferredIdentity, identitySource := session.callOriginatingIdentitiesLocked(carrierProfile)
|
||||
session.mu.Unlock()
|
||||
media, err := newRTPMedia(session.localMediaIP())
|
||||
if err != nil {
|
||||
@@ -80,7 +91,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
}
|
||||
body := media.offerSDP(session.localMediaIP())
|
||||
transportUpper := strings.ToUpper(session.transport)
|
||||
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
||||
from := "<" + fromIdentity + ">;tag=" + session.fromTag
|
||||
to := "<" + target + ">"
|
||||
lines := []string{
|
||||
"INVITE " + target + " SIP/2.0",
|
||||
@@ -100,10 +111,17 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
"To: "+to,
|
||||
"Call-ID: "+callID,
|
||||
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
||||
"Contact: <sip:"+session.identity.user+"@"+session.contactAddress()+";transport="+session.transport+">",
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE",
|
||||
"Supported: timer",
|
||||
session.dialogContactHeader(),
|
||||
"P-Preferred-Identity: <"+preferredIdentity+">",
|
||||
"P-Preferred-Service: "+mmtelServiceURN,
|
||||
`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-Length: "+strconv.Itoa(len(body)), "", "",
|
||||
)
|
||||
@@ -120,12 +138,19 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
session.transactionsMu.Unlock()
|
||||
call := &imsCall{
|
||||
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,
|
||||
routes: routes, media: media,
|
||||
callID: callID, target: target, inviteTarget: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||
routes: routes, media: media, pracked: make(map[string]bool),
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Info("IMS call started",
|
||||
"direction", "outgoing",
|
||||
"identity_source", identitySource,
|
||||
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||
)
|
||||
}
|
||||
session.writeMu.Lock()
|
||||
_, err = session.conn.Write(request)
|
||||
session.writeMu.Unlock()
|
||||
@@ -166,24 +191,26 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
if response == nil {
|
||||
continue
|
||||
}
|
||||
diagnostic := callResponseDiagnostic(response)
|
||||
session.logCallResponse(response, diagnostic)
|
||||
if response.StatusCode < 200 {
|
||||
session.setCallDiagnostic(call.callID, response.StatusCode, response.Reason)
|
||||
if response.StatusCode >= 180 {
|
||||
session.setCallDiagnostic(call.callID, response.StatusCode, diagnostic)
|
||||
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")
|
||||
}
|
||||
if reliableProvisional(response) {
|
||||
go session.sendPRACK(call, response)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
session.callMu.Lock()
|
||||
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()
|
||||
session.updateCallDialogFromResponse(call, response)
|
||||
mediaErr := call.media.configureRemote(response.Body)
|
||||
_ = session.sendACK(call)
|
||||
if mediaErr != nil {
|
||||
@@ -197,13 +224,23 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
}
|
||||
session.setCallMediaReady(call.callID)
|
||||
session.setCallState(call.callID, "active")
|
||||
} else if session.callWasTerminated(call.callID) {
|
||||
// CANCEL normally causes the pending INVITE transaction to finish
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
|
||||
session.startSessionTimer(call, response.value("Session-Expires"))
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
|
||||
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
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, diagnostic)
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, diagnostic)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -223,7 +260,13 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
||||
}
|
||||
request, respond := call.invite, call.respond
|
||||
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 {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
@@ -231,6 +274,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
session.setCallState(id, "active")
|
||||
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||
if call.media.ready() {
|
||||
session.setCallMediaReady(id)
|
||||
}
|
||||
@@ -264,7 +308,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
method := "BYE"
|
||||
if direction == "outgoing" && (state == "dialing" || state == "ringing") {
|
||||
if direction == "outgoing" && (state == "dialing" || state == "ringing" || state == "early_media") {
|
||||
method = "CANCEL"
|
||||
}
|
||||
err := session.sendDialogRequest(ctx, call, method)
|
||||
@@ -288,6 +332,12 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
if callID == "" {
|
||||
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"))
|
||||
target := headerURI(request.value("Contact"))
|
||||
if target == "" {
|
||||
@@ -311,8 +361,9 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
}
|
||||
call := &imsCall{
|
||||
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,
|
||||
pracked: make(map[string]bool),
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
@@ -322,6 +373,21 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
_ = respond(response)
|
||||
}
|
||||
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":
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
session.callMu.Lock()
|
||||
@@ -366,9 +432,199 @@ func (session *Session) sendACK(call *imsCall) error {
|
||||
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 {
|
||||
cseq := call.cseq
|
||||
if method == "BYE" {
|
||||
if method == "BYE" || method == "UPDATE" {
|
||||
session.mu.Lock()
|
||||
cseq = session.cseq
|
||||
session.cseq++
|
||||
@@ -396,15 +652,23 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
||||
if method == "CANCEL" {
|
||||
branch = call.branch
|
||||
}
|
||||
target := call.target
|
||||
if method == "CANCEL" && call.inviteTarget != "" {
|
||||
target = call.inviteTarget
|
||||
}
|
||||
to := call.to
|
||||
if to == "" {
|
||||
to = "<" + call.target + ">"
|
||||
}
|
||||
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),
|
||||
"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)
|
||||
}
|
||||
@@ -413,8 +677,19 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
||||
"To: "+to,
|
||||
"Call-ID: "+call.callID,
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -426,7 +701,7 @@ func (session *Session) localMediaIP() net.IP {
|
||||
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"}
|
||||
reason := reasons[status]
|
||||
if reason == "" {
|
||||
@@ -446,6 +721,14 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
||||
lines = append(lines, "Via: "+value)
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
session.callMu.Lock()
|
||||
if call := session.calls[id]; call != nil {
|
||||
call.public.State = state
|
||||
if state == "active" && call.public.AnsweredAt == nil {
|
||||
now := time.Now().UTC()
|
||||
call.public.AnsweredAt = &now
|
||||
}
|
||||
if state != "ended" && state != "failed" {
|
||||
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.EndedAt = &now
|
||||
if call.sessionCancel != nil {
|
||||
call.sessionCancel()
|
||||
call.sessionCancel = nil
|
||||
}
|
||||
}
|
||||
session.callMu.Unlock()
|
||||
if media != nil {
|
||||
|
||||
@@ -2,6 +2,8 @@ package ims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,7 +12,13 @@ import (
|
||||
)
|
||||
|
||||
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{
|
||||
"INVITE sip:[email protected] SIP/2.0",
|
||||
"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") {
|
||||
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) {
|
||||
@@ -116,3 +127,155 @@ func TestValidCallNumber(t *testing.T) {
|
||||
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
|
||||
// submission that requested a delivery report.
|
||||
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
|
||||
// not include message text or raw protocol payloads.
|
||||
Logger *slog.Logger
|
||||
@@ -704,10 +708,6 @@ func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
||||
}
|
||||
|
||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
|
||||
}
|
||||
|
||||
func (session *Session) abort() {
|
||||
session.refreshCancel()
|
||||
_ = session.conn.Close()
|
||||
@@ -911,9 +911,10 @@ func (session *Session) buildRegister(
|
||||
authorizationHeader string,
|
||||
authorization string,
|
||||
) ([]byte, error) {
|
||||
att310280 := vowifi.IsATT310280(session.request.Identity)
|
||||
if att310280 {
|
||||
expires = 18400
|
||||
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
registerOptions := profile.IMSRegisterOptions
|
||||
if registerOptions.ExpirySeconds != 0 {
|
||||
expires = registerOptions.ExpirySeconds
|
||||
}
|
||||
branch, err := randomHex(12)
|
||||
if err != nil {
|
||||
@@ -924,43 +925,25 @@ func (session *Session) buildRegister(
|
||||
transportUpper := strings.ToUpper(session.transport)
|
||||
requestURI := "sip:" + session.identity.domain
|
||||
routeURI := "sip:" + session.endpoint.address() + ";transport=" + session.transport + ";lr"
|
||||
contact := fmt.Sprintf(
|
||||
"<sip:%s@%s;transport=%s>;+sip.instance=\"<%s>\";+g.3gpp.smsip;audio;"+
|
||||
`+g.3gpp.icsi-ref="%s"`,
|
||||
session.identity.user,
|
||||
contactAddress,
|
||||
session.transport,
|
||||
session.instanceID,
|
||||
"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,
|
||||
)
|
||||
contact := session.buildContact(contactAddress, registerOptions)
|
||||
|
||||
defaultSupported := "path, gruu"
|
||||
defaultAllow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, SUBSCRIBE, NOTIFY"
|
||||
supported := defaultSupported
|
||||
if registerOptions.SupportedHeader != nil {
|
||||
supported = *registerOptions.SupportedHeader
|
||||
}
|
||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||
supported := "path, gruu"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
||||
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"
|
||||
allow := defaultAllow
|
||||
if registerOptions.AllowHeader != nil {
|
||||
allow = *registerOptions.AllowHeader
|
||||
}
|
||||
|
||||
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
|
||||
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
|
||||
userAgent = "SimAdmin VoWiFi"
|
||||
if override := strings.TrimSpace(registerOptions.UserAgent); override != "" &&
|
||||
(userAgent == "" || userAgent == "vocat/1") {
|
||||
userAgent = override
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"REGISTER " + requestURI + " SIP/2.0",
|
||||
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),
|
||||
"Contact: " + contact,
|
||||
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+">")
|
||||
} 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() {
|
||||
lines = append(lines,
|
||||
"Security-Client: "+session.securityClientValue(),
|
||||
@@ -1014,6 +1020,33 @@ func (session *Session) buildRegister(
|
||||
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) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -1366,13 +1399,28 @@ func (session *Session) EnableSMS(ctx context.Context) (vowifi.SMSEvidence, erro
|
||||
return vowifi.SMSEvidence{}, vowifi.ErrIMSNotRegistered
|
||||
case !session.expiresAt.IsZero() && !time.Now().Before(session.expiresAt):
|
||||
return vowifi.SMSEvidence{}, ErrRegistrationExpired
|
||||
case !session.smsContactConfirmed:
|
||||
case !session.smsCapabilityReady():
|
||||
return vowifi.SMSEvidence{Ready: false}, ErrSMSCapabilityNotConfirmed
|
||||
default:
|
||||
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 {
|
||||
if ctx == nil {
|
||||
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)
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"p-access-network-info",
|
||||
"p-visited-network-id",
|
||||
"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 headers["authorization"] != "" {
|
||||
return errors.New("initial REGISTER unexpectedly authenticated")
|
||||
|
||||
@@ -534,25 +534,7 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
||||
for _, protocol := range flow.protocols {
|
||||
operations = append(operations, xfrmOperation{
|
||||
description: flow.description + " " + protocol + " policy",
|
||||
arguments: []string{
|
||||
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",
|
||||
},
|
||||
arguments: xfrmPolicyArgs(flow, protocol, false),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -568,16 +550,7 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
||||
protocol := flow.protocols[protocolIndex]
|
||||
operations = append(operations, xfrmOperation{
|
||||
description: "delete " + flow.description + " " + protocol + " policy",
|
||||
arguments: []string{
|
||||
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,
|
||||
},
|
||||
arguments: xfrmPolicyArgs(flow, protocol, true),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -606,6 +579,44 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
||||
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 {
|
||||
description string
|
||||
family string
|
||||
@@ -650,7 +661,7 @@ func xfrmFlows(config IPSecSAConfig) []xfrmFlow {
|
||||
{
|
||||
description: "P-CSCF-client to UE-server", family: family,
|
||||
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
||||
sourcePort: config.PCSCFClientPort, destinationPort: config.UEServerPort,
|
||||
sourcePort: 0, destinationPort: config.UEServerPort,
|
||||
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
||||
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
||||
protocols: []string{"tcp", "udp"},
|
||||
@@ -772,7 +783,7 @@ func zeroBytes(value []byte) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -916,9 +927,7 @@ func (session *Session) validProtectedUDPSource(remote *net.UDPAddr) bool {
|
||||
return false
|
||||
}
|
||||
expectedIP := addressIP(session.conn.RemoteAddr())
|
||||
return expectedIP != nil &&
|
||||
expectedIP.Equal(remote.IP) &&
|
||||
remote.Port == session.securityAgreement.selected.portClient
|
||||
return expectedIP != nil && expectedIP.Equal(remote.IP)
|
||||
}
|
||||
|
||||
func (session *Session) effectiveSecurityMode() string {
|
||||
|
||||
@@ -531,8 +531,8 @@ func serveProtectedRegistrar(
|
||||
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
||||
strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
||||
return result, errors.New("registration transaction advertised or sent MESSAGE")
|
||||
!strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
||||
return result, errors.New("registration transaction did not advertise MESSAGE correctly")
|
||||
}
|
||||
|
||||
if _, err := protectedConnection.Write(testResponse(
|
||||
|
||||
@@ -287,14 +287,18 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
"tcp 40666 50600 out": false,
|
||||
"udp 40666 50600 out": false,
|
||||
"tcp 50600 40666 in": false,
|
||||
"tcp 50601 55610 in": false,
|
||||
"udp 50601 55610 in": false,
|
||||
"tcp * 55610 in": false,
|
||||
"udp * 55610 in": false,
|
||||
"tcp 55610 50601 out": false,
|
||||
}
|
||||
for _, operation := range install[4:] {
|
||||
sourcePort := "*"
|
||||
if value, ok := optionalArgumentAfter(operation.arguments, "sport"); ok {
|
||||
sourcePort = value
|
||||
}
|
||||
key := strings.Join([]string{
|
||||
argumentAfter(t, operation.arguments, "proto"),
|
||||
argumentAfter(t, operation.arguments, "sport"),
|
||||
sourcePort,
|
||||
argumentAfter(t, operation.arguments, "dport"),
|
||||
argumentAfter(t, operation.arguments, "dir"),
|
||||
}, " ")
|
||||
@@ -397,6 +401,15 @@ func argumentAfter(t *testing.T, arguments []string, name string) string {
|
||||
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 {
|
||||
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
||||
return false
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/vowifi"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
|
||||
const (
|
||||
smsContentType = "application/vnd.3gpp.sms"
|
||||
ussiContentType = "application/vnd.3gpp.ussd"
|
||||
sipMessageRetransmitT1 = 500 * time.Millisecond
|
||||
sipMessageRetransmitMax = 4 * time.Second
|
||||
)
|
||||
@@ -52,6 +54,7 @@ type ReceivedSMS struct {
|
||||
CallID string
|
||||
RawRPDU string
|
||||
RawTPDU string
|
||||
DecodeError string
|
||||
}
|
||||
|
||||
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
|
||||
@@ -69,6 +72,24 @@ type ReceivedSMSStatus struct {
|
||||
CallID string
|
||||
RawRPDU 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 {
|
||||
@@ -91,11 +112,14 @@ func (session *Session) startRuntimeReceivers() error {
|
||||
|
||||
session.receiveDone.Add(1)
|
||||
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)
|
||||
go session.acceptProtectedTCP()
|
||||
}
|
||||
if session.securityActive && session.transport == "udp" && session.protectedUDP != nil {
|
||||
if session.securityActive && session.protectedUDP != nil {
|
||||
session.receiveDone.Add(1)
|
||||
go session.readProtectedUDP()
|
||||
}
|
||||
@@ -140,6 +164,8 @@ func (session *Session) acceptProtectedTCP() {
|
||||
return
|
||||
}
|
||||
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound TCP rejected", nil,
|
||||
"stage", "source_filter", "remote", connection.RemoteAddr().String())
|
||||
_ = connection.Close()
|
||||
continue
|
||||
}
|
||||
@@ -186,6 +212,8 @@ func (session *Session) readProtectedUDP() {
|
||||
return
|
||||
}
|
||||
if !session.validProtectedUDPSource(remote) {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound UDP rejected", nil,
|
||||
"stage", "source_filter", "remote", remote.String(), "packet_bytes", count)
|
||||
continue
|
||||
}
|
||||
packet, err := parseSIPPacket(buffer[:count])
|
||||
@@ -208,8 +236,9 @@ func (session *Session) validProtectedTCPSource(address net.Addr) bool {
|
||||
return false
|
||||
}
|
||||
expected := addressIP(session.conn.RemoteAddr())
|
||||
return expected != nil && expected.Equal(remote.IP) &&
|
||||
remote.Port == session.securityAgreement.selected.portClient
|
||||
// Require P-CSCF IP. Do not require port-c (50601): some cores originate
|
||||
// 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) {
|
||||
@@ -335,10 +364,16 @@ func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byt
|
||||
return
|
||||
}
|
||||
status := 200
|
||||
ussiMessage := false
|
||||
switch request.Method {
|
||||
case "OPTIONS":
|
||||
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
|
||||
}
|
||||
default:
|
||||
@@ -354,11 +389,17 @@ func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byt
|
||||
}
|
||||
if status != 200 || 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)
|
||||
}
|
||||
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,
|
||||
"stage", "sip_accepted")
|
||||
go session.processSMSMessage(request)
|
||||
@@ -376,6 +417,14 @@ func supportsSMSContentType(value string) bool {
|
||||
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) {
|
||||
reason := map[int]string{200: "OK", 405: "Method Not Allowed", 415: "Unsupported Media Type", 488: "Not Acceptable Here"}[status]
|
||||
if reason == "" {
|
||||
@@ -406,7 +455,7 @@ func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, erro
|
||||
lines = append(lines, "Allow: REGISTER, MESSAGE, OPTIONS")
|
||||
}
|
||||
if status == 415 {
|
||||
lines = append(lines, "Accept: "+smsContentType)
|
||||
lines = append(lines, "Accept: "+smsContentType+", "+ussiContentType)
|
||||
}
|
||||
lines = append(lines, "Content-Length: 0", "", "")
|
||||
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))
|
||||
return
|
||||
}
|
||||
message, err := device.DecodeSMSDeliverTPDU(rpdu.tpdu)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
message, decodeErr := device.DecodeSMSDeliverTPDU(rpdu.tpdu)
|
||||
receivedAt := time.Now().UTC()
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
if message.Direction == device.SMSDirectionStatusReport {
|
||||
if message.MessageReference == nil || message.StatusCode == nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
|
||||
"stage", "tpdu", "rp_reference", int(rpdu.reference))
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
carrierProfile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
|
||||
if decodeErr != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed; persisting raw payload", request,
|
||||
"stage", "tpdu", "payload_source", payloadSource,
|
||||
"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{
|
||||
DeviceID: session.request.DeviceID,
|
||||
IMSI: session.request.Identity.IMSI,
|
||||
To: message.To,
|
||||
MessageReference: *message.MessageReference,
|
||||
StatusCode: *message.StatusCode,
|
||||
MessageReference: intPtrValue(message.MessageReference),
|
||||
StatusCode: intPtrValue(message.StatusCode),
|
||||
DeliveryStatus: message.DeliveryStatus,
|
||||
ServiceCenterTimestamp: message.ServiceCenterTimestamp,
|
||||
DischargeTimestamp: message.DischargeTimestamp,
|
||||
@@ -469,12 +517,15 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
||||
CallID: callID,
|
||||
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
||||
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
||||
DecodeError: errorString(decodeErr),
|
||||
}
|
||||
if session.provider.config.OnSMSStatus != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err = session.provider.config.OnSMSStatus(ctx, status)
|
||||
cancel()
|
||||
if (message.MessageReference == nil || message.StatusCode == nil) && decodeErr == nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
|
||||
"stage", "tpdu", "rp_reference", int(rpdu.reference))
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
err := session.invokeSMSStatusCallback(status)
|
||||
if err != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status persistence failed", request,
|
||||
"stage", "status_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||
@@ -483,55 +534,84 @@ func (session *Session) processSMSMessage(request *sipRequest) {
|
||||
}
|
||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS status report processed", request,
|
||||
"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")
|
||||
return
|
||||
}
|
||||
if message.Direction != device.SMSDirectionReceived {
|
||||
|
||||
case message.Direction == device.SMSDirectionReceived || decodeErr != nil:
|
||||
var serviceCenterTimestamp *time.Time
|
||||
if message.ServiceCenterTimestamp != nil {
|
||||
value := message.ServiceCenterTimestamp.UTC()
|
||||
serviceCenterTimestamp = &value
|
||||
}
|
||||
received := ReceivedSMS{
|
||||
// A retransmission inside the same SIP transaction is idempotent, but a
|
||||
// fresh Call-ID/RP reference is a distinct network delivery and must stay
|
||||
// visible even when its TPDU and text happen to be identical.
|
||||
MessageID: fmt.Sprintf("ims:%s:%d", callID, rpdu.reference),
|
||||
DeviceID: session.request.DeviceID,
|
||||
IMSI: session.request.Identity.IMSI,
|
||||
From: message.From,
|
||||
Text: message.Text,
|
||||
Timestamp: receivedAt,
|
||||
ServiceCenterTimestamp: serviceCenterTimestamp,
|
||||
Encoding: message.Encoding,
|
||||
Concat: message.Concat,
|
||||
RPReference: int(rpdu.reference),
|
||||
CallID: callID,
|
||||
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
||||
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
||||
DecodeError: errorString(decodeErr),
|
||||
}
|
||||
err := session.invokeSMSCallback(received)
|
||||
if err != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
|
||||
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
|
||||
return
|
||||
}
|
||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
|
||||
"stage", "sms_callback", "payload_source", payloadSource,
|
||||
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
|
||||
"concatenated", message.Concat != nil, "decode_error", decodeErr != nil)
|
||||
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
||||
|
||||
default:
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
|
||||
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
|
||||
return
|
||||
}
|
||||
var serviceCenterTimestamp *time.Time
|
||||
if message.ServiceCenterTimestamp != nil {
|
||||
value := message.ServiceCenterTimestamp.UTC()
|
||||
serviceCenterTimestamp = &value
|
||||
}
|
||||
|
||||
func (session *Session) invokeSMSCallback(received ReceivedSMS) error {
|
||||
if session.provider.config.OnSMS == nil {
|
||||
return nil
|
||||
}
|
||||
received := ReceivedSMS{
|
||||
// A retransmission inside the same SIP transaction is idempotent, but a
|
||||
// fresh Call-ID/RP reference is a distinct network delivery and must stay
|
||||
// visible even when its TPDU and text happen to be identical.
|
||||
MessageID: fmt.Sprintf("ims:%s:%d", callID, rpdu.reference),
|
||||
DeviceID: session.request.DeviceID,
|
||||
IMSI: session.request.Identity.IMSI,
|
||||
From: message.From,
|
||||
Text: message.Text,
|
||||
Timestamp: receivedAt,
|
||||
ServiceCenterTimestamp: serviceCenterTimestamp,
|
||||
Encoding: message.Encoding,
|
||||
Concat: message.Concat,
|
||||
RPReference: int(rpdu.reference),
|
||||
CallID: callID,
|
||||
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
|
||||
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
|
||||
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
|
||||
}
|
||||
if session.provider.config.OnSMS != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err = session.provider.config.OnSMS(ctx, received)
|
||||
cancel()
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
|
||||
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
|
||||
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
|
||||
return
|
||||
return *value
|
||||
}
|
||||
|
||||
func errorString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
|
||||
"stage", "sms_callback", "payload_source", payloadSource,
|
||||
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
|
||||
"concatenated", message.Concat != nil)
|
||||
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
|
||||
return err.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) {
|
||||
logger := slog.Default()
|
||||
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()
|
||||
|
||||
session.mu.Lock()
|
||||
if session.closed || !session.evidence.Registered || !session.smsContactConfirmed {
|
||||
if session.closed || !session.evidence.Registered || !session.smsCapabilityReady() {
|
||||
session.mu.Unlock()
|
||||
return vowifi.SMSSubmitResult{}, vowifi.ErrSMSNotReady
|
||||
}
|
||||
@@ -802,6 +1111,20 @@ func (session *Session) sendSIPMessage(
|
||||
target string,
|
||||
body []byte,
|
||||
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) {
|
||||
callToken, err := randomHex(18)
|
||||
if err != nil {
|
||||
@@ -841,7 +1164,11 @@ func (session *Session) sendSIPMessage(
|
||||
"Call-ID: "+callID,
|
||||
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
|
||||
"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",
|
||||
"Allow: MESSAGE",
|
||||
)
|
||||
@@ -849,7 +1176,7 @@ func (session *Session) sendSIPMessage(
|
||||
lines = append(lines, "In-Reply-To: "+inReplyTo)
|
||||
}
|
||||
lines = append(lines,
|
||||
"Content-Type: "+smsContentType,
|
||||
"Content-Type: "+contentType,
|
||||
"Content-Transfer-Encoding: binary",
|
||||
"Content-Length: "+strconv.Itoa(len(body)),
|
||||
"", "",
|
||||
@@ -1017,3 +1344,4 @@ func (session *Session) closeInboundConnections() {
|
||||
}
|
||||
|
||||
var _ vowifi.SMSSender = (*Session)(nil)
|
||||
var _ vowifi.USSISender = (*Session)(nil)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/textproto"
|
||||
@@ -158,6 +159,57 @@ func TestSupportsSMSContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsUSSIContentType(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{ussiContentType, true},
|
||||
{"Application/Vnd.3gpp.Ussd; charset=binary", true},
|
||||
{smsContentType, false},
|
||||
{"text/plain", false},
|
||||
} {
|
||||
if got := supportsUSSIContentType(test.value); got != test.want {
|
||||
t.Errorf("supportsUSSIContentType(%q) = %v, want %v", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeUSSDBody(t *testing.T) {
|
||||
for _, text := range []string{"*100#", "Main menu 中文"} {
|
||||
body, dcs, err := encodeUSSDBody(text)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeUSSDBody(%q) error = %v", text, err)
|
||||
}
|
||||
if dcs == nil || *dcs != 0x48 {
|
||||
t.Fatalf("encodeUSSDBody(%q) dcs = %v, want 0x48", text, dcs)
|
||||
}
|
||||
decoded := decodeUSSDBody(body, *dcs)
|
||||
if decoded != text {
|
||||
t.Fatalf("decodeUSSDBody(%q) = %q, want %q", text, decoded, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUSSDString(t *testing.T) {
|
||||
text := "Main menu"
|
||||
encoded, dcs, err := encodeUSSDBody(text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := append([]byte{byte(len(encoded) + 1), byte(*dcs)}, encoded...)
|
||||
raw, gotDCS, gotText := extractUSSDString(body)
|
||||
if gotText != text || gotDCS == nil || *gotDCS != *dcs || !bytes.Equal(raw, body) {
|
||||
t.Fatalf("extractUSSDString(%x) = (%q, %v, %q)", body, raw, gotDCS, gotText)
|
||||
}
|
||||
|
||||
// A plain raw body without a length/DCS prefix falls back to DCS 0x0F.
|
||||
raw, gotDCS, gotText = extractUSSDString([]byte("fallback"))
|
||||
if gotDCS == nil || *gotDCS != 0x0F || gotText != "fallback" || !bytes.Equal(raw, []byte("fallback")) {
|
||||
t.Fatalf("extractUSSDString fallback = (%q, %v, %q)", raw, gotDCS, gotText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
||||
config := Config{SMSCenterByPLMN: map[string]string{
|
||||
"23410": "+447802000332",
|
||||
@@ -539,3 +591,368 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
||||
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestSessionReceivesUSSIOverIMS(t *testing.T) {
|
||||
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
_ = listener.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
received := make(chan ReceivedUSSD, 1)
|
||||
serverDone := make(chan error, 1)
|
||||
readyForClose := make(chan struct{})
|
||||
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
go func() { serverDone <- serveInboundUSSI(listener, nonce, readyForClose) }()
|
||||
provider, err := NewProvider(
|
||||
smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}},
|
||||
Config{
|
||||
PCSCF: listener.LocalAddr().String(), LocalAddress: "127.0.0.1",
|
||||
Transport: "udp", TransactionTimeout: 3 * time.Second, SecurityMode: SecurityDisabled,
|
||||
OnUSSD: func(_ context.Context, message ReceivedUSSD) error {
|
||||
received <- message
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := provider.Start(context.Background(), vowifi.IMSRequest{
|
||||
DeviceID: "ec20",
|
||||
Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
|
||||
Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{
|
||||
Established: true, LocalIPv4: "127.0.0.1", PCSCF: []string{listener.LocalAddr().String()},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case message := <-received:
|
||||
if message.Text != "Main menu" || message.From != "sip:[email protected]" || message.CallID != "network-ussd-1" {
|
||||
t.Fatalf("received = %#v", message)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for inbound USSI")
|
||||
}
|
||||
select {
|
||||
case <-readyForClose:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for USSI MESSAGE acceptance")
|
||||
}
|
||||
if err := session.Close(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveInboundUSSI(listener *net.UDPConn, nonce string, readyForClose chan<- struct{}) error {
|
||||
packet := make([]byte, 65535)
|
||||
count, remote, err := listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, headers, err := parseTestRequest(packet[:count])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callID := headers["call-id"]
|
||||
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", callID, headers["cseq"], []string{
|
||||
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
|
||||
}), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, headers, err = parseTestRequest(packet[:count])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], []string{
|
||||
"Contact: " + headers["contact"] + ";expires=600",
|
||||
}), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := buildUSSDBody("Main menu")
|
||||
request := []byte(strings.Join([]string{
|
||||
"MESSAGE sip:[email protected] SIP/2.0",
|
||||
"Via: SIP/2.0/UDP " + listener.LocalAddr().String() + ";branch=z9hG4bKussd",
|
||||
"From: <sip:[email protected]>;tag=gw",
|
||||
"To: <sip:[email protected]>",
|
||||
"P-Asserted-Identity: <sip:[email protected]>",
|
||||
"Call-ID: network-ussd-1",
|
||||
"CSeq: 1 MESSAGE",
|
||||
"Content-Type: application/vnd.3gpp.ussd",
|
||||
"Content-Transfer-Encoding: binary",
|
||||
fmt.Sprintf("Content-Length: %d", len(body)), "", "",
|
||||
}, "\r\n"))
|
||||
request = append(request, body...)
|
||||
if _, err = listener.WriteToUDP(request, remote); err != nil {
|
||||
return err
|
||||
}
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := parseSIPResponse(packet[:count])
|
||||
if err != nil || response.StatusCode != 200 {
|
||||
return fmt.Errorf("USSI MESSAGE response = (%#v, %v)", response, err)
|
||||
}
|
||||
close(readyForClose)
|
||||
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, headers, err = parseTestRequest(packet[:count])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headers["expires"] != "0" {
|
||||
return errors.New("expected deregistration")
|
||||
}
|
||||
_, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], nil), remote)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestSessionReceivesMalformedSMSBestEffort(t *testing.T) {
|
||||
request := &sipRequest{
|
||||
Headers: map[string][]string{
|
||||
"content-type": {smsContentType},
|
||||
"content-transfer-encoding": {"binary"},
|
||||
"call-id": {"malformed-test"},
|
||||
"p-asserted-identity": {"<sip:[email protected]>"},
|
||||
},
|
||||
Body: []byte{0x01, 0x2a, 0x00, 0x00, 0x03, 0xff, 0xff, 0xff},
|
||||
}
|
||||
|
||||
received := make(chan ReceivedSMS, 1)
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
Logger: slog.Default(),
|
||||
OnSMS: func(_ context.Context, message ReceivedSMS) error {
|
||||
received <- message
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
request: vowifi.IMSRequest{DeviceID: "ec20", Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"}},
|
||||
conn: &fakeConn{},
|
||||
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||
fromTag: "tag",
|
||||
nextRPReference: 1,
|
||||
}
|
||||
|
||||
session.processSMSMessage(request)
|
||||
|
||||
select {
|
||||
case message := <-received:
|
||||
if message.DecodeError == "" {
|
||||
t.Fatal("expected DecodeError to be set")
|
||||
}
|
||||
if message.RawRPDU == "" || message.RawTPDU == "" {
|
||||
t.Fatalf("expected raw payloads to be preserved, got %#v", message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for best-effort SMS callback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAllowsSMSWithoutContactConfirmationWhenProfilePermits(t *testing.T) {
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{Logger: slog.Default()}},
|
||||
request: vowifi.IMSRequest{
|
||||
Identity: vowifi.SIMIdentity{HomeMCC: "515", HomeMNC: "66"},
|
||||
},
|
||||
evidence: vowifi.IMSEvidence{
|
||||
Registered: true,
|
||||
RegistrationState: "registered",
|
||||
},
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
evidence, err := session.EnableSMS(context.Background())
|
||||
if err != nil || !evidence.Ready {
|
||||
t.Fatalf("EnableSMS() = (%#v, %v), want ready for DITO profile", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRequiresSMSContactConfirmationByDefault(t *testing.T) {
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{Logger: slog.Default()}},
|
||||
request: vowifi.IMSRequest{
|
||||
Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"},
|
||||
},
|
||||
evidence: vowifi.IMSEvidence{
|
||||
Registered: true,
|
||||
RegistrationState: "registered",
|
||||
},
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
evidence, err := session.EnableSMS(context.Background())
|
||||
if !errors.Is(err, ErrSMSCapabilityNotConfirmed) || evidence.Ready {
|
||||
t.Fatalf("EnableSMS() = (%#v, %v), want not-ready", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildUSSDBody(text string) []byte {
|
||||
encoded, dcs, err := encodeUSSDBody(text)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return append([]byte{byte(len(encoded) + 1), byte(*dcs)}, encoded...)
|
||||
}
|
||||
|
||||
func TestSessionSendsUSSIOverIMS(t *testing.T) {
|
||||
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
_ = listener.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
serverDone := make(chan error, 1)
|
||||
readyForClose := make(chan struct{})
|
||||
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
go func() { serverDone <- serveOutboundUSSI(listener, nonce, readyForClose) }()
|
||||
provider, err := NewProvider(
|
||||
smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}},
|
||||
Config{
|
||||
PCSCF: listener.LocalAddr().String(), LocalAddress: "127.0.0.1",
|
||||
Transport: "udp", TransactionTimeout: 3 * time.Second, SecurityMode: SecurityDisabled,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := provider.Start(context.Background(), vowifi.IMSRequest{
|
||||
DeviceID: "ec20",
|
||||
Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
|
||||
Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{
|
||||
Established: true, LocalIPv4: "127.0.0.1", PCSCF: []string{listener.LocalAddr().String()},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := session.(vowifi.USSISender).SendUSSI(context.Background(), vowifi.USSISubmitRequest{Code: "*100#"})
|
||||
if err != nil {
|
||||
t.Fatalf("SendUSSI error = %v", err)
|
||||
}
|
||||
if result.Status != "final" || result.Text != "Reply" || result.SIPCode != 200 {
|
||||
t.Fatalf("SendUSSI result = %#v", result)
|
||||
}
|
||||
select {
|
||||
case <-readyForClose:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for USSI transaction to complete")
|
||||
}
|
||||
if err := session.Close(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveOutboundUSSI(listener *net.UDPConn, nonce string, readyForClose chan<- struct{}) error {
|
||||
packet := make([]byte, 65535)
|
||||
count, remote, err := listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, headers, err := parseTestRequest(packet[:count])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registerCallID := headers["call-id"]
|
||||
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", registerCallID, headers["cseq"], []string{
|
||||
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
|
||||
}), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, headers, err = parseTestRequest(packet[:count])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], []string{
|
||||
"Contact: " + headers["contact"] + ";expires=600",
|
||||
}), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := parseSIPPacket(packet[:count])
|
||||
if err != nil || message.Request == nil {
|
||||
return fmt.Errorf("outbound MESSAGE parse: %v", err)
|
||||
}
|
||||
if message.Request.Method != "MESSAGE" ||
|
||||
!strings.HasPrefix(message.Request.URI, "sip:") ||
|
||||
strings.ToLower(message.Request.value("Content-Type")) != ussiContentType ||
|
||||
message.Request.value("Request-Disposition") != "no-fork" ||
|
||||
message.Request.value("Allow") != "MESSAGE" {
|
||||
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
|
||||
}
|
||||
_, _, text := extractUSSDString(message.Request.Body)
|
||||
if text != "*100#" {
|
||||
return fmt.Errorf("USSI text = %q, want *100#", text)
|
||||
}
|
||||
replyBody := buildUSSDBody("Reply")
|
||||
reply := []byte(strings.Join([]string{
|
||||
"SIP/2.0 200 OK",
|
||||
"Call-ID: " + message.Request.value("Call-ID"),
|
||||
"CSeq: " + message.Request.value("CSeq"),
|
||||
"Content-Type: application/vnd.3gpp.ussd",
|
||||
"Content-Transfer-Encoding: binary",
|
||||
fmt.Sprintf("Content-Length: %d", len(replyBody)), "", "",
|
||||
}, "\r\n"))
|
||||
reply = append(reply, replyBody...)
|
||||
if _, err = listener.WriteToUDP(reply, remote); err != nil {
|
||||
return err
|
||||
}
|
||||
close(readyForClose)
|
||||
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, headers, err = parseTestRequest(packet[:count])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headers["expires"] != "0" {
|
||||
return errors.New("expected deregistration")
|
||||
}
|
||||
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
|
||||
return err
|
||||
}
|
||||
|
||||
// fakeConn is a minimal net.Conn useful for tests that only need LocalAddr
|
||||
// to succeed and do not care about the actual SIP MESSAGE delivery report.
|
||||
type fakeConn struct{}
|
||||
|
||||
func (*fakeConn) Read([]byte) (int, error) { return 0, errors.New("fakeConn: closed") }
|
||||
func (*fakeConn) Write(source []byte) (int, error) { return len(source), nil }
|
||||
func (*fakeConn) Close() error { return nil }
|
||||
func (*fakeConn) LocalAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
|
||||
}
|
||||
func (*fakeConn) RemoteAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
|
||||
}
|
||||
func (*fakeConn) SetDeadline(time.Time) error { return nil }
|
||||
func (*fakeConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (*fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
@@ -560,6 +560,35 @@ func (orchestrator *Orchestrator) SendSMS(
|
||||
return sender.SendSMS(ctx, request)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
orchestrator.mu.Lock()
|
||||
resources := orchestrator.resources
|
||||
|
||||
@@ -318,6 +318,27 @@ func (manager *Manager) SendSMS(
|
||||
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) {
|
||||
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -53,6 +53,7 @@ var (
|
||||
ErrTunnelNotEstablished = errors.New("vowifi: tunnel is not established")
|
||||
ErrIMSNotRegistered = errors.New("vowifi: IMS is not registered")
|
||||
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")
|
||||
ErrResponderAUTHRequired = errors.New("vowifi: verified IKE responder AUTH is required")
|
||||
// ErrCleanupIncomplete marks a teardown that released its local IMS, tunnel,
|
||||
@@ -297,6 +298,27 @@ type SMSSubmitResult struct {
|
||||
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 {
|
||||
ICCID string
|
||||
Number string
|
||||
@@ -397,6 +419,13 @@ type SMSSender interface {
|
||||
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
|
||||
// available to an authenticated extension.
|
||||
type Call struct {
|
||||
@@ -405,6 +434,7 @@ type Call struct {
|
||||
Direction string `json:"direction"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
AnsweredAt *time.Time `json:"answered_at,omitempty"`
|
||||
SIPCode int `json:"sip_code,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
MediaReady bool `json:"media_ready,omitempty"`
|
||||
|
||||
+39
-5
@@ -3,9 +3,9 @@
|
||||
# vocat install / update script for systemd and OpenWrt/procd deployments.
|
||||
#
|
||||
# Usage:
|
||||
# bash install.sh [version] # run directly when already root
|
||||
# sudo bash install.sh [version] # run through sudo as a normal user
|
||||
# bash install.sh --check-env # check VoWiFi host prerequisites
|
||||
# bash install.sh [--check-env] [--skip-vowifi-check] [version] # run directly when already root
|
||||
# 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
|
||||
#
|
||||
# Behavior:
|
||||
# - Prompts for script language (中文 / English) as soon as it runs.
|
||||
@@ -195,6 +195,39 @@ install_linux_ip_tool() {
|
||||
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() {
|
||||
msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..."
|
||||
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."
|
||||
fi
|
||||
die \
|
||||
"当前 Linux 内核不支持 XFRM/IPsec,VoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 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."
|
||||
"当前 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; 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 ---------------------------
|
||||
@@ -538,6 +571,7 @@ enable_and_start() {
|
||||
|
||||
# --- Main --------------------------------------------------------------------
|
||||
detect_arch
|
||||
install_qmi_support
|
||||
install_pcsc_support
|
||||
check_vowifi_environment
|
||||
if [ "$CHECK_ENV" -eq 1 ]; then
|
||||
|
||||
Generated
+1896
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { WindowConsoleRegular, WarningRegular } from "@fluentui/react-icons";
|
||||
import { api } from "../../api";
|
||||
import { Button, Input, Select } from "../ui";
|
||||
import { Button, Input, Select, Switch } from "../ui";
|
||||
import { AT_COMMAND_GROUPS } from "./atCommands";
|
||||
import { AtLogEntry, AtTypingBubble, type AtLogItem } from "./AtLogEntry";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
@@ -19,6 +19,7 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt
|
||||
const [template, setTemplate] = useState("");
|
||||
const [timeoutMs, setTimeoutMs] = useState<number>(10000);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [force, setForce] = useState(false);
|
||||
const [log, setLog] = useState<AtLogItem[]>([]);
|
||||
|
||||
const hasAtPort = String(atPort || "").trim().length > 0;
|
||||
@@ -40,7 +41,7 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt
|
||||
try {
|
||||
const res = await api<{ ok?: boolean; response?: string; result?: string }>(`/devices/${deviceId}/actions/at`, {
|
||||
method: "POST",
|
||||
body: { cmd: command, timeoutMs: timeoutMs || 10000 },
|
||||
body: { cmd: command, timeoutMs: timeoutMs || 10000, force },
|
||||
});
|
||||
setLog((prev) => [
|
||||
...prev,
|
||||
@@ -120,6 +121,13 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt
|
||||
</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">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AtTypingBubble } from "./AtLogEntry";
|
||||
import { tf, useI18n } from "../../lib/i18n";
|
||||
|
||||
interface UssdResult {
|
||||
status?: number;
|
||||
status?: string;
|
||||
text?: string;
|
||||
rawText?: string;
|
||||
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 r = (res?.result || {}) as Record<string, unknown>;
|
||||
return {
|
||||
status: r.status as number | undefined,
|
||||
status: r.status as string | undefined,
|
||||
text: (r.text as string) || "",
|
||||
rawText: ((r.rawText as string) || (r.rawXml as string) || "") as string,
|
||||
dcs: r.dcs as number | undefined,
|
||||
@@ -59,15 +59,15 @@ export function DeviceUssdTab({ deviceId }: { deviceId: string }) {
|
||||
const v = await callUssd(command);
|
||||
if (v.channel) setChannel(v.channel);
|
||||
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 }]);
|
||||
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 }]);
|
||||
clearSession();
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -23,7 +23,9 @@ export function DiscoveredDeviceRow({
|
||||
? t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。")
|
||||
: device.discoveryIssue === "pcsc_driver_missing"
|
||||
? 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 (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -40,23 +40,35 @@ function ProbeResultPanel({ probe }: { probe: UpstreamProbeResult }) {
|
||||
const { t } = useI18n();
|
||||
const reachable = !!probe.reachable;
|
||||
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 udpState: ProbeState = !handshakeOk ? "pending" : udpOk ? "ok" : "fail";
|
||||
const associateState: ProbeState = !handshakeOk ? "pending" : associateOk ? "ok" : "fail";
|
||||
const udpState: ProbeState = !associateOk ? "pending" : udpOk ? "ok" : "fail";
|
||||
return (
|
||||
<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={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
||||
<ProbeRow
|
||||
state={udpState}
|
||||
state={associateState}
|
||||
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 ? (
|
||||
<div className="text-[11px] text-gray-400">
|
||||
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
||||
</div>
|
||||
) : 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.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
||||
</div>
|
||||
@@ -127,7 +139,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
|
||||
</div>
|
||||
{probe ? (
|
||||
<div className="space-y-3">
|
||||
<SectionHeader tone={probe.udpAssociateOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||
<SectionHeader tone={probe.udpExchangeOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||
<ProbeResultPanel probe={probe} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -20,8 +20,13 @@ export interface UpstreamProbeResult {
|
||||
reachable?: boolean;
|
||||
handshakeOk?: boolean;
|
||||
udpAssociateOk?: boolean;
|
||||
udpExchangeOk?: boolean;
|
||||
authMethod?: string;
|
||||
relayAddr?: string;
|
||||
dnsServer?: string;
|
||||
dnsName?: string;
|
||||
dnsRcode?: number;
|
||||
roundTripMs?: number;
|
||||
diagnosis?: string;
|
||||
hint?: 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 })} />
|
||||
<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("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
||||
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
|
||||
</div>
|
||||
<Field label="Token">
|
||||
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user