mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-18 22:03:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8137fc875b | ||
|
|
1df338f9b3 | ||
|
|
20f91fac72 | ||
|
|
30880f6612 | ||
|
|
fdb2ac45ce | ||
|
|
95299a0a39 | ||
|
|
1a4032d013 | ||
|
|
6ec950bfd2 | ||
|
|
04dd026e95 | ||
|
|
a2a72dfd30 | ||
|
|
67079c99b9 | ||
|
|
f949001480 | ||
|
|
fa8afb9571 | ||
|
|
bfda29193a | ||
|
|
ed64fd428a | ||
|
|
f84a1f99b1 | ||
|
|
e2177a6e9a | ||
|
|
115598757a | ||
|
|
297d2c1839 | ||
|
|
7ba30132f9 | ||
|
|
82494f519b | ||
|
|
5eee89a92a | ||
|
|
ae3a2a6eea |
@@ -49,13 +49,13 @@ jobs:
|
|||||||
BUILD_TIME=${{ github.event.repository.updated_at }}
|
BUILD_TIME=${{ github.event.repository.updated_at }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|
||||||
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
|
- name: Verify ${{ matrix.platform }} runtime, QMI, and smart-card stack
|
||||||
run: |
|
run: |
|
||||||
docker run --rm --platform '${{ matrix.platform }}' \
|
docker run --rm --platform '${{ matrix.platform }}' \
|
||||||
vocat-smoke:${{ matrix.arch }} version
|
vocat-smoke:${{ matrix.arch }} version
|
||||||
docker run --rm --platform '${{ matrix.platform }}' \
|
docker run --rm --platform '${{ matrix.platform }}' \
|
||||||
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \
|
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \
|
||||||
'command -v pcscd && test -d /usr/lib/pcsc/drivers'
|
'command -v qmicli && command -v qmi-network && command -v pcscd && test -d /usr/lib/pcsc/drivers'
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: smoke
|
needs: smoke
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
|||||||
|
|
||||||
# ---- Stage 3: minimal runtime ----
|
# ---- Stage 3: minimal runtime ----
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite tzdata && \
|
RUN apk add --no-cache ca-certificates ccid iproute2 pcsc-lite qmi-utils tzdata && \
|
||||||
addgroup -S -g 1000 vocat && \
|
addgroup -S -g 1000 vocat && \
|
||||||
adduser -S -D -H -u 1000 -G vocat vocat
|
adduser -S -D -H -u 1000 -G vocat vocat
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
Copyright (c) 2013, Dustin L. Howett. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
The views and conclusions contained in the software and documentation are
|
||||||
|
those of the authors and should not be interpreted as representing official
|
||||||
|
policies, either expressed or implied, of the FreeBSD Project.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Parts of this package were made available under the license covering the Go
|
||||||
|
language and all attended core libraries. That license follows.
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||||
|
used to endorse or promote products derived from this software without
|
||||||
|
specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||||
|
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||||
|
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||||
|
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -197,6 +197,18 @@ managers. On Debian/Ubuntu, the equivalent manual setup is
|
|||||||
VoCat keeps the reader visible in the add-device dialog and reports the missing
|
VoCat keeps the reader visible in the add-device dialog and reports the missing
|
||||||
service or driver instead of silently hiding it.
|
service or driver instead of silently hiding it.
|
||||||
|
|
||||||
|
### QMI command-line utilities
|
||||||
|
|
||||||
|
VoCat uses `qmicli` to verify that a QMI control channel is ready and
|
||||||
|
`qmi-network` to manage packet-data sessions. The one-click installer installs
|
||||||
|
and verifies the corresponding utilities automatically. For manual deployment,
|
||||||
|
Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses
|
||||||
|
`pacman -S libqmi`, and Alpine uses `apk add qmi-utils`.
|
||||||
|
|
||||||
|
`vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB
|
||||||
|
driver binding or asserting DTR. If the utility is unavailable, the command
|
||||||
|
stops with an installation hint and leaves the current device state untouched.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
|
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
|
||||||
@@ -212,6 +224,10 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
|||||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
||||||
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
||||||
|
|
||||||
|
User-supplied Apple carrier bundles can be converted into reviewable,
|
||||||
|
allow-listed carrier profiles with `vocat carrier import-ipcc`; see
|
||||||
|
[docs/CARRIER_IPCC_IMPORT.md](docs/CARRIER_IPCC_IMPORT.md).
|
||||||
|
|
||||||
Administrator credentials are stored only in SQLite. Initialize an empty
|
Administrator credentials are stored only in SQLite. Initialize an empty
|
||||||
database once with `vocat bootstrap-admin`; environment variables and JSON
|
database once with `vocat bootstrap-admin`; environment variables and JSON
|
||||||
configuration cannot set or overwrite the administrator username or password.
|
configuration cannot set or overwrite the administrator username or password.
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"vocat/internal/config"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runCarrier(args []string, stdout io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errors.New("usage: vocat carrier import-ipcc [flags] FILE.ipcc")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "import-ipcc":
|
||||||
|
return runCarrierImportIPCC(args[1:], stdout)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown carrier subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCarrierImportIPCC(args []string, stdout io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("carrier import-ipcc", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
var bundle string
|
||||||
|
var profileID string
|
||||||
|
var profileDir string
|
||||||
|
var install bool
|
||||||
|
var documentOnly bool
|
||||||
|
flags.StringVar(&bundle, "bundle", "", "bundle name when an IPCC contains more than one carrier bundle")
|
||||||
|
flags.StringVar(&profileID, "id", "", "override the generated carrier profile ID")
|
||||||
|
flags.StringVar(&profileDir, "profile-dir", "", "installation directory (default: next to the VoCat database)")
|
||||||
|
flags.BoolVar(&install, "install", false, "atomically install the reviewed generated profile")
|
||||||
|
flags.BoolVar(&documentOnly, "document-only", false, "print only the generated carrier profile document")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 1 {
|
||||||
|
return errors.New("usage: vocat carrier import-ipcc [--bundle NAME] [--id ID] [--document-only] [--install] [--profile-dir DIR] FILE.ipcc")
|
||||||
|
}
|
||||||
|
if documentOnly && install {
|
||||||
|
return errors.New("--document-only and --install cannot be used together")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(profileDir) != "" && !install {
|
||||||
|
return errors.New("--profile-dir requires --install")
|
||||||
|
}
|
||||||
|
result, err := vowifi.ImportCarrierIPCC(flags.Arg(0), vowifi.IPCCImportOptions{
|
||||||
|
Bundle: bundle,
|
||||||
|
ProfileID: profileID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if documentOnly {
|
||||||
|
_, err := stdout.Write(result.Document)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
installedPath := ""
|
||||||
|
if install {
|
||||||
|
profileDir = strings.TrimSpace(profileDir)
|
||||||
|
if profileDir == "" {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load configuration for carrier profile directory: %w", err)
|
||||||
|
}
|
||||||
|
profileDir = filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||||
|
}
|
||||||
|
installedPath, err = vowifi.InstallCarrierIPCCResult(result, profileDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if absolute, absoluteErr := filepath.Abs(installedPath); absoluteErr == nil {
|
||||||
|
installedPath = absolute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output := struct {
|
||||||
|
vowifi.IPCCImportResult
|
||||||
|
InstalledPath string `json:"installed_path,omitempty"`
|
||||||
|
RestartRequired bool `json:"restart_required,omitempty"`
|
||||||
|
}{
|
||||||
|
IPCCImportResult: result,
|
||||||
|
InstalledPath: installedPath,
|
||||||
|
RestartRequired: installedPath != "",
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(output)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"howett.net/plist"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunCarrierImportIPCCPreviewsAndInstallsExplicitly(t *testing.T) {
|
||||||
|
archivePath := filepath.Join(t.TempDir(), "test.ipcc")
|
||||||
|
file, err := os.Create(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := zip.NewWriter(file)
|
||||||
|
entry, err := archive.Create("Payload/Test.bundle/carrier.plist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := plist.NewEncoder(&encoded).Encode(map[string]any{
|
||||||
|
"CarrierName": "Test Carrier",
|
||||||
|
"SupportedSIMs": []any{"99901"},
|
||||||
|
"SupportedPLMNs": []any{"99901"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview bytes.Buffer
|
||||||
|
if err := runCarrier([]string{"import-ipcc", "--document-only", archivePath}, &preview); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var document struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(preview.Bytes(), &document); err != nil || document.Version != 1 {
|
||||||
|
t.Fatalf("preview = %q, version=%d, error=%v", preview.String(), document.Version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installDir := t.TempDir()
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := runCarrier([]string{
|
||||||
|
"import-ipcc", "--id", "cli-test", "--install", "--profile-dir", installDir, archivePath,
|
||||||
|
}, &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var installed struct {
|
||||||
|
InstalledPath string `json:"installed_path"`
|
||||||
|
RestartRequired bool `json:"restart_required"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &installed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !installed.RestartRequired || filepath.Base(installed.InstalledPath) != "cli-test.json" {
|
||||||
|
t.Fatalf("install output = %s", output.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(installDir, "cli-test.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ Usage:
|
|||||||
vocat serve Run the server in the foreground (use from a TTY when
|
vocat serve Run the server in the foreground (use from a TTY when
|
||||||
vocat without arguments would enter the menu).
|
vocat without arguments would enter the menu).
|
||||||
vocat version Print the build version and exit.
|
vocat version Print the build version and exit.
|
||||||
|
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths.
|
||||||
|
Use --repair-dji-qmi on Linux to restore the factory-ID
|
||||||
|
DJI/Baiwang 2ca3:4006 AT/QMI interface bindings and wake
|
||||||
|
QMI without changing NV.
|
||||||
|
vocat carrier import-ipcc [flags] FILE.ipcc
|
||||||
|
Convert an Apple carrier bundle into a reviewable VoCat
|
||||||
|
profile. Preview is the default; --install writes it to
|
||||||
|
carrier-profiles.d and takes effect after restart.
|
||||||
|
Flags: --bundle NAME --id ID --document-only --install
|
||||||
|
--profile-dir DIR.
|
||||||
vocat update Check GitHub for a newer release and self-update.
|
vocat update Check GitHub for a newer release and self-update.
|
||||||
Flags:
|
Flags:
|
||||||
--check Only report whether an update is available.
|
--check Only report whether an update is available.
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/modem"
|
||||||
|
"vocat/internal/pcsc"
|
||||||
|
"vocat/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type doctorCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Evidence any `json:"evidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type doctorReport struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Checks []doctorCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type djiQMIRepairResult struct {
|
||||||
|
USBName string `json:"usb_name"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
USBDevice string `json:"usb_device"`
|
||||||
|
OriginalDriver string `json:"original_driver,omitempty"`
|
||||||
|
SerialInterfaces []string `json:"serial_interfaces,omitempty"`
|
||||||
|
SerialDevices []string `json:"serial_devices,omitempty"`
|
||||||
|
ATDevice string `json:"at_device,omitempty"`
|
||||||
|
ControlDevice string `json:"control_device"`
|
||||||
|
NetworkInterface string `json:"network_interface,omitempty"`
|
||||||
|
QMIProbe string `json:"qmi_probe"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDoctor(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(os.Stderr)
|
||||||
|
proxyAddress := flags.String("proxy", "", "SOCKS5 host:port to test")
|
||||||
|
proxyUsername := flags.String("proxy-username", "", "SOCKS5 username")
|
||||||
|
passwordEnv := flags.String("proxy-password-env", "VOCAT_DOCTOR_PROXY_PASSWORD", "environment variable containing the proxy password")
|
||||||
|
repairDJI := flags.Bool("repair-dji-qmi", false, "bind DJI 2ca3:4006 interfaces 0-3 to option and interface 4 to qmi_wwan, then assert DTR (Linux/root only; no NV write)")
|
||||||
|
jsonOutput := flags.Bool("json", false, "write machine-readable JSON")
|
||||||
|
timeout := flags.Duration("timeout", 12*time.Second, "per-probe timeout")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 || *timeout <= 0 || *timeout > time.Minute {
|
||||||
|
return errors.New("usage: vocat doctor [--repair-dji-qmi] [--proxy host:port] [--proxy-username name] [--proxy-password-env ENV] [--json]")
|
||||||
|
}
|
||||||
|
report := doctorReport{Time: time.Now().UTC(), OS: runtime.GOOS, Arch: runtime.GOARCH}
|
||||||
|
add := func(name, status, code, message string, evidence any) {
|
||||||
|
report.Checks = append(report.Checks, doctorCheck{Name: name, Status: status, Code: code, Message: message, Evidence: evidence})
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := os.ReadFile("/proc/version"); err == nil && strings.Contains(strings.ToLower(string(data)), "microsoft") {
|
||||||
|
add("host", "warning", "wsl_usbip_detected", "WSL/USBIP detected; QMI control transfers may time out even when /dev/cdc-wdm exists", nil)
|
||||||
|
} else {
|
||||||
|
add("host", "passed", "native_host", "No WSL kernel marker detected", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
defer cancel()
|
||||||
|
if *repairDJI {
|
||||||
|
result, err := repairDJIQMI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("repair DJI QMI binding: %w", err)
|
||||||
|
}
|
||||||
|
add("dji_qmi_repair", "passed", "dji_usb_interfaces_repaired", "DJI serial interfaces 0-3 were bound to option and interface 4 to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, discoverErr := modem.NewSystemDiscoverer().Discover(ctx)
|
||||||
|
if discoverErr != nil {
|
||||||
|
add("modem_discovery", "failed", "modem_discovery_failed", discoverErr.Error(), nil)
|
||||||
|
} else if len(candidates) == 0 {
|
||||||
|
add("modem_discovery", "warning", "no_modem", "No USB modem was discovered", nil)
|
||||||
|
} else {
|
||||||
|
add("modem_discovery", "passed", "modem_discovered", fmt.Sprintf("Discovered %d modem candidate(s)", len(candidates)), candidates)
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
name := "modem:" + candidate.ID
|
||||||
|
if candidate.HasATPort() {
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 5*time.Second))
|
||||||
|
client, openErr := (modem.SerialOpener{}).Open(probeContext, candidate.ATPort)
|
||||||
|
if openErr != nil {
|
||||||
|
add(name+":at", "warning", "at_open_failed", openErr.Error(), candidate.ATPort.OpenPath())
|
||||||
|
} else {
|
||||||
|
response, commandErr := client.Execute(probeContext, "AT+CFUN?")
|
||||||
|
_ = client.Close()
|
||||||
|
if commandErr != nil {
|
||||||
|
add(name+":at", "warning", "at_probe_failed", commandErr.Error(), candidate.ATPort.OpenPath())
|
||||||
|
} else {
|
||||||
|
add(name+":at", "passed", "at_ready", "AT control channel responded to a read-only CFUN query", response.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancelProbe()
|
||||||
|
} else {
|
||||||
|
add(name+":at", "failed", "at_missing", "No AT port was selected", nil)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(candidate.QMIControl) == "" {
|
||||||
|
add(name+":qmi", "warning", "qmi_missing", "No cdc-wdm/QMI control node was discovered", nil)
|
||||||
|
} else if qmicli, lookErr := exec.LookPath("qmicli"); lookErr != nil {
|
||||||
|
add(name+":qmi", "warning", "qmicli_missing", "QMI node exists but qmicli is unavailable for an active DMS check", candidate.QMIControl)
|
||||||
|
} else {
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(context.Background(), minDuration(*timeout, 8*time.Second))
|
||||||
|
command := exec.CommandContext(probeContext, qmicli, "-d", candidate.QMIControl, "--dms-get-operating-mode")
|
||||||
|
output, commandErr := command.CombinedOutput()
|
||||||
|
message := strings.TrimSpace(string(output))
|
||||||
|
cancelProbe()
|
||||||
|
if commandErr != nil {
|
||||||
|
code := "qmi_cid_failed"
|
||||||
|
if errors.Is(probeContext.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timed out") {
|
||||||
|
code = "qmi_cid_timeout"
|
||||||
|
}
|
||||||
|
add(name+":qmi", "failed", code, "qmicli DMS client allocation/read failed", message)
|
||||||
|
} else {
|
||||||
|
add(name+":qmi", "passed", "qmi_dms_ready", "qmicli allocated DMS and completed a read-only request", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readers, readerErr := pcsc.New().Readers(ctx)
|
||||||
|
if readerErr == nil {
|
||||||
|
add("pcsc", "passed", "pcsc_ready", fmt.Sprintf("PC/SC reported %d reader(s)", len(readers)), readers)
|
||||||
|
} else if errors.Is(readerErr, pcsc.ErrUnsupported) || errors.Is(readerErr, pcsc.ErrUnavailable) {
|
||||||
|
add("pcsc", "warning", "pcsc_unavailable", readerErr.Error(), nil)
|
||||||
|
} else {
|
||||||
|
add("pcsc", "failed", "pcsc_failed", readerErr.Error(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(*proxyAddress) != "" {
|
||||||
|
password := os.Getenv(strings.TrimSpace(*passwordEnv))
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(context.Background(), *timeout)
|
||||||
|
result, probeErr := proxy.ProbeSOCKS5(probeContext, *proxyAddress, *proxyUsername, password, *timeout)
|
||||||
|
cancelProbe()
|
||||||
|
status := "passed"
|
||||||
|
if probeErr != nil {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
add("proxy_udp", status, result.Diagnosis, result.Hint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *jsonOutput {
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(report)
|
||||||
|
}
|
||||||
|
for _, check := range report.Checks {
|
||||||
|
fmt.Printf("%-8s %-26s %-28s %s\n", strings.ToUpper(check.Status), check.Name, check.Code, check.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func minDuration(left, right time.Duration) time.Duration {
|
||||||
|
if left < right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return right
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
djiVendorID = "2ca3"
|
||||||
|
djiProductID = "4006"
|
||||||
|
djiFirstSerialIndex = 0
|
||||||
|
djiLastSerialIndex = 3
|
||||||
|
djiATIndex = 2
|
||||||
|
djiQMIIndex = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type usbControlTransfer struct {
|
||||||
|
RequestType uint8
|
||||||
|
Request uint8
|
||||||
|
Value uint16
|
||||||
|
Index uint16
|
||||||
|
Length uint16
|
||||||
|
Timeout uint32
|
||||||
|
Data uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairDJIQMI(ctx context.Context) (djiQMIRepairResult, error) {
|
||||||
|
qmicli, err := exec.LookPath("qmicli")
|
||||||
|
if err != nil {
|
||||||
|
return djiQMIRepairResult{}, errors.New("qmicli is required to verify DJI QMI readiness; install libqmi-utils on Debian/Ubuntu/Fedora, libqmi on Arch Linux, or qmi-utils on Alpine")
|
||||||
|
}
|
||||||
|
return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) {
|
||||||
|
return repairDJIQMIAt(attemptContext, "/sys", "/dev", qmicli)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryDJIQMI(
|
||||||
|
ctx context.Context,
|
||||||
|
maxAttempts int,
|
||||||
|
delay time.Duration,
|
||||||
|
attempt func(context.Context) (djiQMIRepairResult, error),
|
||||||
|
) (djiQMIRepairResult, error) {
|
||||||
|
var result djiQMIRepairResult
|
||||||
|
var err error
|
||||||
|
for attemptNumber := 1; attemptNumber <= maxAttempts; attemptNumber++ {
|
||||||
|
result, err = attempt(ctx)
|
||||||
|
result.Attempts = attemptNumber
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(time.Duration(attemptNumber) * delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return result, errors.Join(err, ctx.Err())
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("failed after %d DTR repair attempt(s): %w", result.Attempts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairDJIQMIAt(ctx context.Context, sysRoot, devRoot, qmicli string) (result djiQMIRepairResult, returnErr error) {
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
entries, err := os.ReadDir(usbRoot)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("read USB topology: %w", err)
|
||||||
|
}
|
||||||
|
var usbNames []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
devicePath := filepath.Join(usbRoot, entry.Name())
|
||||||
|
vendor, vendorErr := readTrimmedFile(filepath.Join(devicePath, "idVendor"))
|
||||||
|
product, productErr := readTrimmedFile(filepath.Join(devicePath, "idProduct"))
|
||||||
|
if vendorErr == nil && productErr == nil &&
|
||||||
|
strings.EqualFold(vendor, djiVendorID) && strings.EqualFold(product, djiProductID) {
|
||||||
|
usbNames = append(usbNames, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(usbNames) != 1 {
|
||||||
|
return result, fmt.Errorf("expected exactly one DJI %s:%s USB device, found %d", djiVendorID, djiProductID, len(usbNames))
|
||||||
|
}
|
||||||
|
result.USBName = usbNames[0]
|
||||||
|
result.Interface = fmt.Sprintf("%s:1.%d", result.USBName, djiQMIIndex)
|
||||||
|
devicePath := filepath.Join(usbRoot, result.USBName)
|
||||||
|
interfacePath := filepath.Join(usbRoot, result.Interface)
|
||||||
|
if _, err := os.Stat(interfacePath); err != nil {
|
||||||
|
return result, fmt.Errorf("DJI QMI interface %s unavailable: %w", result.Interface, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
busNumber, err := readUSBNumber(filepath.Join(devicePath, "busnum"))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
deviceNumber, err := readUSBNumber(filepath.Join(devicePath, "devnum"))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.USBDevice = filepath.Join(devRoot, "bus", "usb", fmt.Sprintf("%03d", busNumber), fmt.Sprintf("%03d", deviceNumber))
|
||||||
|
|
||||||
|
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||||
|
if err := ensureUSBDriverLoaded(ctx, driversRoot, "qmi_wwan", "qmi_wwan"); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := ensureUSBDriverLoaded(ctx, driversRoot, "option", "option"); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// qmi_wwan's USB dynamic ID is device-wide. Leaving it installed makes it
|
||||||
|
// probe every vendor-specific interface after a USBIP reconnect; on this DJI
|
||||||
|
// composition that can turn interfaces 1-3 into bogus cdc-wdm devices and
|
||||||
|
// remove the AT port. Remove it before detaching anything, then add it only
|
||||||
|
// briefly below while interface 4 is the sole unbound interface.
|
||||||
|
qmiDriverRoot := filepath.Join(driversRoot, "qmi_wwan")
|
||||||
|
if err := removeDynamicUSBID(qmiDriverRoot, djiVendorID+" "+djiProductID); err != nil {
|
||||||
|
return result, fmt.Errorf("remove broad DJI qmi_wwan dynamic ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialInterfaces, serialDevices, atDevice, err := bindDJISerialInterfaces(ctx, sysRoot, devRoot, usbRoot, driversRoot, result.USBName)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.SerialInterfaces = serialInterfaces
|
||||||
|
result.SerialDevices = serialDevices
|
||||||
|
result.ATDevice = atDevice
|
||||||
|
|
||||||
|
result.OriginalDriver = usbInterfaceDriver(interfacePath)
|
||||||
|
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
|
||||||
|
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver)
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaceDetached := false
|
||||||
|
restoreOriginal := func() {
|
||||||
|
if !interfaceDetached {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
|
||||||
|
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
|
||||||
|
}
|
||||||
|
switch result.OriginalDriver {
|
||||||
|
case "qmi_wwan":
|
||||||
|
_ = bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface)
|
||||||
|
case "option":
|
||||||
|
_ = writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "bind"), result.Interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if returnErr != nil {
|
||||||
|
restoreOriginal()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if result.OriginalDriver != "" {
|
||||||
|
if err := writeSysfs(filepath.Join(driversRoot, result.OriginalDriver, "unbind"), result.Interface); err != nil {
|
||||||
|
return result, fmt.Errorf("unbind %s from %s: %w", result.OriginalDriver, result.Interface, err)
|
||||||
|
}
|
||||||
|
interfaceDetached = true
|
||||||
|
}
|
||||||
|
if err := assertUSBDTR(result.USBDevice, djiQMIIndex); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bindDJIQMIInterface(qmiDriverRoot, interfacePath, result.Interface); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
result.ControlDevice = firstDeviceNode(filepath.Join(interfacePath, "usbmisc"), devRoot, "cdc-wdm")
|
||||||
|
result.NetworkInterface = firstEntryName(filepath.Join(interfacePath, "net"), "")
|
||||||
|
if result.ControlDevice != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return result, fmt.Errorf("qmi_wwan bound but no cdc-wdm node appeared for %s", result.Interface)
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
// The requested driver topology is now established. A later DMS timeout is
|
||||||
|
// a QMI/USBIP readiness problem, so do not roll interface 4 back to option.
|
||||||
|
interfaceDetached = false
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
|
||||||
|
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
|
||||||
|
probeContextErr := probeContext.Err()
|
||||||
|
cancelProbe()
|
||||||
|
result.QMIProbe = strings.TrimSpace(string(output))
|
||||||
|
if probeErr != nil {
|
||||||
|
if probeContextErr != nil {
|
||||||
|
probeErr = errors.Join(probeErr, probeContextErr)
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindDJIQMIInterface(driverRoot, interfacePath, interfaceName string) (returnErr error) {
|
||||||
|
bindPath := filepath.Join(driverRoot, "bind")
|
||||||
|
dynamicIDAdded := false
|
||||||
|
defer func() {
|
||||||
|
if dynamicIDAdded {
|
||||||
|
removeErr := removeDynamicUSBID(driverRoot, djiVendorID+" "+djiProductID)
|
||||||
|
if returnErr == nil && removeErr != nil {
|
||||||
|
returnErr = fmt.Errorf("remove temporary DJI qmi_wwan dynamic ID: %w", removeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := writeSysfs(bindPath, interfaceName); err != nil {
|
||||||
|
newIDErr := writeSysfs(filepath.Join(driverRoot, "new_id"), djiVendorID+" "+djiProductID)
|
||||||
|
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
|
||||||
|
return fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
|
||||||
|
}
|
||||||
|
dynamicIDAdded = true
|
||||||
|
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
|
||||||
|
if retryErr := writeSysfs(bindPath, interfaceName); retryErr != nil {
|
||||||
|
return fmt.Errorf("bind qmi_wwan to %s: %w", interfaceName, retryErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
|
||||||
|
return fmt.Errorf("interface %s driver is %q after qmi_wwan bind", interfaceName, driver)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureUSBDriverLoaded(ctx context.Context, driversRoot, driverName, moduleName string) error {
|
||||||
|
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("inspect %s driver: %w", driverName, err)
|
||||||
|
}
|
||||||
|
modprobe, err := exec.LookPath("modprobe")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s is not loaded and modprobe is unavailable", driverName)
|
||||||
|
}
|
||||||
|
if output, loadErr := exec.CommandContext(ctx, modprobe, moduleName).CombinedOutput(); loadErr != nil {
|
||||||
|
return fmt.Errorf("load %s: %w: %s", moduleName, loadErr, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(driversRoot, driverName)); err != nil {
|
||||||
|
return fmt.Errorf("%s driver is unavailable after loading module %s: %w", driverName, moduleName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindDJISerialInterfaces(
|
||||||
|
ctx context.Context,
|
||||||
|
sysRoot, devRoot, usbRoot, driversRoot, usbName string,
|
||||||
|
) ([]string, []string, string, error) {
|
||||||
|
interfaceNames := make([]string, 0, djiLastSerialIndex-djiFirstSerialIndex+1)
|
||||||
|
interfacePaths := make([]string, 0, cap(interfaceNames))
|
||||||
|
needsDynamicID := false
|
||||||
|
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||||
|
name := fmt.Sprintf("%s:1.%d", usbName, index)
|
||||||
|
path := filepath.Join(usbRoot, name)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("DJI serial interface %s unavailable: %w", name, err)
|
||||||
|
}
|
||||||
|
driver := usbInterfaceDriver(path)
|
||||||
|
if driver != "" && driver != "option" && driver != "qmi_wwan" {
|
||||||
|
return nil, nil, "", fmt.Errorf("refusing to replace unexpected driver %q on %s", driver, name)
|
||||||
|
}
|
||||||
|
interfaceNames = append(interfaceNames, name)
|
||||||
|
interfacePaths = append(interfacePaths, path)
|
||||||
|
needsDynamicID = needsDynamicID || driver != "option"
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsDynamicID {
|
||||||
|
// Detach every false QMI claim before option's new_id triggers probing.
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
if usbInterfaceDriver(path) != "qmi_wwan" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "unbind"), interfaceNames[index]); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("unbind qmi_wwan from serial interface %s: %w", interfaceNames[index], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionSerialRoot := filepath.Join(sysRoot, "bus", "usb-serial", "drivers", "option1")
|
||||||
|
if _, err := os.Stat(optionSerialRoot); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("option USB-serial driver is unavailable: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeSysfs(filepath.Join(optionSerialRoot, "new_id"), djiVendorID+" "+djiProductID); err != nil && !errors.Is(err, syscall.EEXIST) {
|
||||||
|
return nil, nil, "", fmt.Errorf("register DJI option dynamic ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
if usbInterfaceDriver(path) == "option" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writeSysfs(filepath.Join(driversRoot, "option", "bind"), interfaceNames[index]); err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("bind option to %s: %w", interfaceNames[index], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
if driver := usbInterfaceDriver(path); driver != "option" {
|
||||||
|
return nil, nil, "", fmt.Errorf("serial interface %s driver is %q after option bind", interfaceNames[index], driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
serialDevices := make([]string, len(interfacePaths))
|
||||||
|
for {
|
||||||
|
complete := true
|
||||||
|
for index, path := range interfacePaths {
|
||||||
|
name := firstEntryName(path, "ttyUSB")
|
||||||
|
if name == "" {
|
||||||
|
complete = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serialDevices[index] = filepath.Join(devRoot, name)
|
||||||
|
}
|
||||||
|
if complete {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, nil, "", fmt.Errorf("option bound but not all ttyUSB nodes appeared for %s", usbName)
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return interfaceNames, serialDevices, serialDevices[djiATIndex-djiFirstSerialIndex], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDynamicUSBID(driverRoot, id string) error {
|
||||||
|
path := filepath.Join(driverRoot, "remove_id")
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeSysfs(path, id); err != nil && !errors.Is(err, syscall.ENODEV) && !errors.Is(err, syscall.ENOENT) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertUSBDTR(devicePath string, interfaceIndex int) error {
|
||||||
|
fd, err := unix.Open(devicePath, unix.O_RDWR|unix.O_CLOEXEC, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open USB device %s: %w", devicePath, err)
|
||||||
|
}
|
||||||
|
defer unix.Close(fd)
|
||||||
|
if err := setUSBControlLineState(fd, interfaceIndex, false); err != nil {
|
||||||
|
return fmt.Errorf("clear CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if err := setUSBControlLineState(fd, interfaceIndex, true); err != nil {
|
||||||
|
return fmt.Errorf("assert CDC DTR on %s interface %d: %w", devicePath, interfaceIndex, err)
|
||||||
|
}
|
||||||
|
// QDC507 acknowledges the control transfer before its QMI firmware is ready.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUSBControlLineState(fd, interfaceIndex int, dtr bool) error {
|
||||||
|
var value uint16
|
||||||
|
if dtr {
|
||||||
|
value = 1 // USB_CDC_CTRL_DTR
|
||||||
|
}
|
||||||
|
transfer := usbControlTransfer{
|
||||||
|
RequestType: 0x21, // host-to-device, class, interface
|
||||||
|
Request: 0x22, // USB_CDC_REQ_SET_CONTROL_LINE_STATE
|
||||||
|
Value: value,
|
||||||
|
Index: uint16(interfaceIndex),
|
||||||
|
Timeout: 5000,
|
||||||
|
}
|
||||||
|
const ioctlDirectionReadWrite = uintptr(3)
|
||||||
|
request := ioctlDirectionReadWrite<<30 |
|
||||||
|
uintptr(unsafe.Sizeof(transfer))<<16 |
|
||||||
|
uintptr('U')<<8
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), request, uintptr(unsafe.Pointer(&transfer)))
|
||||||
|
if errno != 0 {
|
||||||
|
return errno
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTrimmedFile(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUSBNumber(path string) (int, error) {
|
||||||
|
value, err := readTrimmedFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read %s: %w", filepath.Base(path), err)
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(value)
|
||||||
|
if err != nil || number < 1 || number > 999 {
|
||||||
|
return 0, fmt.Errorf("invalid %s %q", filepath.Base(path), value)
|
||||||
|
}
|
||||||
|
return number, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usbInterfaceDriver(interfacePath string) string {
|
||||||
|
resolved, err := filepath.EvalSymlinks(filepath.Join(interfacePath, "driver"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Base(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSysfs(path, value string) error {
|
||||||
|
file, err := os.OpenFile(path, os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, writeErr := file.WriteString(value)
|
||||||
|
closeErr := file.Close()
|
||||||
|
return errors.Join(writeErr, closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDeviceNode(directory, devRoot, prefix string) string {
|
||||||
|
name := firstEntryName(directory, prefix)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(devRoot, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstEntryName(directory, prefix string) string {
|
||||||
|
entries, err := os.ReadDir(directory)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), prefix) {
|
||||||
|
return entry.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDJIUSBControlTransferLayout(t *testing.T) {
|
||||||
|
var transfer usbControlTransfer
|
||||||
|
if got := unsafe.Sizeof(transfer); got != 24 {
|
||||||
|
t.Fatalf("usbControlTransfer size = %d, want 24", got)
|
||||||
|
}
|
||||||
|
if transfer.RequestType != 0 || transfer.Request != 0 {
|
||||||
|
t.Fatal("zero-value transfer unexpectedly initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadUSBNumber(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "busnum")
|
||||||
|
if err := os.WriteFile(path, []byte("12\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := readUSBNumber(path); err != nil || got != 12 {
|
||||||
|
t.Fatalf("readUSBNumber() = %d, %v, want 12, nil", got, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := readUSBNumber(path); err == nil {
|
||||||
|
t.Fatal("readUSBNumber(0) unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteSysfsDoesNotCreateMissingPath(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "missing")
|
||||||
|
if err := writeSysfs(path, "value"); err == nil {
|
||||||
|
t.Fatal("writeSysfs(missing) unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("missing sysfs path was created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairDJIQMIRequiresQMICLIBeforeUSBAccess(t *testing.T) {
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
_, err := repairDJIQMI(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("repairDJIQMI() unexpectedly succeeded without qmicli")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "qmicli is required") || !strings.Contains(err.Error(), "libqmi-utils") {
|
||||||
|
t.Fatalf("repairDJIQMI() error = %q, want an actionable qmicli prerequisite error", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "DTR repair attempt") || strings.Contains(err.Error(), "USB topology") {
|
||||||
|
t.Fatalf("repairDJIQMI() touched the repair path before checking qmicli: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDJISerialInterfaceLayout(t *testing.T) {
|
||||||
|
if djiFirstSerialIndex != 0 || djiLastSerialIndex != 3 || djiATIndex != 2 || djiQMIIndex != 4 {
|
||||||
|
t.Fatalf(
|
||||||
|
"DJI interface layout = serial %d-%d, AT %d, QMI %d; want serial 0-3, AT 2, QMI 4",
|
||||||
|
djiFirstSerialIndex,
|
||||||
|
djiLastSerialIndex,
|
||||||
|
djiATIndex,
|
||||||
|
djiQMIIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindDJISerialInterfacesAlreadyCorrect(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
|
||||||
|
optionRoot := filepath.Join(driversRoot, "option")
|
||||||
|
if err := os.MkdirAll(optionRoot, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index := djiFirstSerialIndex; index <= djiLastSerialIndex; index++ {
|
||||||
|
interfacePath := filepath.Join(usbRoot, fmt.Sprintf("1-1:1.%d", index))
|
||||||
|
if err := os.MkdirAll(filepath.Join(interfacePath, fmt.Sprintf("ttyUSB%d", index)), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(optionRoot, filepath.Join(interfacePath, "driver")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaces, devices, atDevice, err := bindDJISerialInterfaces(
|
||||||
|
context.Background(),
|
||||||
|
sysRoot,
|
||||||
|
devRoot,
|
||||||
|
usbRoot,
|
||||||
|
driversRoot,
|
||||||
|
"1-1",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bindDJISerialInterfaces() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(interfaces) != 4 || interfaces[2] != "1-1:1.2" {
|
||||||
|
t.Fatalf("interfaces = %#v, want four interfaces with AT at 1-1:1.2", interfaces)
|
||||||
|
}
|
||||||
|
if len(devices) != 4 || devices[2] != filepath.Join(devRoot, "ttyUSB2") {
|
||||||
|
t.Fatalf("devices = %#v, want four devices with AT at ttyUSB2", devices)
|
||||||
|
}
|
||||||
|
if atDevice != filepath.Join(devRoot, "ttyUSB2") {
|
||||||
|
t.Fatalf("AT device = %q, want %q", atDevice, filepath.Join(devRoot, "ttyUSB2"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDJIQMISucceedsAfterTransientFailures(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
result, err := retryDJIQMI(context.Background(), 3, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
return djiQMIRepairResult{}, errors.New("transient QMI timeout")
|
||||||
|
}
|
||||||
|
return djiQMIRepairResult{ControlDevice: "/dev/cdc-wdm0"}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retryDJIQMI() error = %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 3 || result.Attempts != 3 {
|
||||||
|
t.Fatalf("attempts = %d, result.Attempts = %d, want 3", attempts, result.Attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDJIQMIStopsAfterBoundedAttempts(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
_, err := retryDJIQMI(context.Background(), 2, time.Millisecond, func(context.Context) (djiQMIRepairResult, error) {
|
||||||
|
attempts++
|
||||||
|
return djiQMIRepairResult{}, errors.New("persistent failure")
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("retryDJIQMI() unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func repairDJIQMI(context.Context) (djiQMIRepairResult, error) {
|
||||||
|
return djiQMIRepairResult{}, errors.New("DJI QMI repair is supported only on Linux")
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDoctorHelpIsSuccessful(t *testing.T) {
|
||||||
|
if err := runDoctor([]string{"--help"}); err != nil {
|
||||||
|
t.Fatalf("runDoctor(--help) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,3 +29,29 @@ func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
|
|||||||
t.Fatal("installer replaces the current binary before validating database compatibility")
|
t.Fatal("installer replaces the current binary before validating database compatibility")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallerProvidesRequiredQMIUtilities(t *testing.T) {
|
||||||
|
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
script := string(scriptBytes)
|
||||||
|
for _, required := range []string{
|
||||||
|
"install_qmi_support()",
|
||||||
|
"command -v qmicli",
|
||||||
|
"command -v qmi-network",
|
||||||
|
"apt-get install -y libqmi-utils",
|
||||||
|
"dnf install -y libqmi-utils",
|
||||||
|
"pacman -Sy --noconfirm libqmi",
|
||||||
|
"apk add --no-cache qmi-utils",
|
||||||
|
"Could not install or find qmicli/qmi-network",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(script, required) {
|
||||||
|
t.Errorf("installer is missing required QMI handling %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainStart := strings.LastIndex(script, "# --- Main ")
|
||||||
|
if mainStart < 0 || !strings.Contains(script[mainStart:], "install_qmi_support") {
|
||||||
|
t.Error("installer does not install QMI utilities from its main path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+22
-16
@@ -77,6 +77,16 @@ func main() {
|
|||||||
logger.Error("update failed", "error", err)
|
logger.Error("update failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
case "doctor":
|
||||||
|
if err := runDoctor(rest); err != nil {
|
||||||
|
logger.Error("doctor failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "carrier":
|
||||||
|
if err := runCarrier(rest, os.Stdout); err != nil {
|
||||||
|
logger.Error("carrier command failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
case "menu":
|
case "menu":
|
||||||
if err := runMenu(logger); err != nil {
|
if err := runMenu(logger); err != nil {
|
||||||
logger.Error("menu failed", "error", err)
|
logger.Error("menu failed", "error", err)
|
||||||
@@ -120,6 +130,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load configuration: %w", err)
|
return fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
carrierProfileDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||||
|
if err := vowifi.LoadCarrierProfileDirectory(carrierProfileDir); err != nil {
|
||||||
|
return fmt.Errorf("load installed carrier profiles: %w", err)
|
||||||
|
}
|
||||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -721,27 +735,19 @@ func newVoWiFiOrchestrator(
|
|||||||
if apn == "" {
|
if apn == "" {
|
||||||
apn = "ims"
|
apn = "ims"
|
||||||
}
|
}
|
||||||
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||||
|
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||||
}
|
}
|
||||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
// The userspace SWu data plane carries protected P-CSCF signalling over
|
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||||
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
|
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||||
Transport: "tcp",
|
// alternate transport only if no SIP response was observed.
|
||||||
TransportByPLMN: map[string]string{
|
Transport: "tcp",
|
||||||
"23410": "udp",
|
AutoTransportFallback: true,
|
||||||
"234010": "udp",
|
|
||||||
},
|
|
||||||
// Some UK SIM profiles leave EF_SMSP/AT+CSCA empty. Keep fallbacks scoped
|
|
||||||
// to their HPLMN so an O2/giffgaff SIM can never inherit Vodafone's SMSC.
|
|
||||||
SMSCenterByPLMN: map[string]string{
|
|
||||||
"23410": "+447802000332",
|
|
||||||
"234010": "+447802000332",
|
|
||||||
"23415": "+447785016005",
|
|
||||||
"234015": "+447785016005",
|
|
||||||
},
|
|
||||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||||
extra, _ := json.Marshal(map[string]any{
|
extra, _ := json.Marshal(map[string]any{
|
||||||
"transport": "ims",
|
"transport": "ims",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
"vocat/internal/vowifi/integration"
|
"vocat/internal/vowifi/integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,10 @@ func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Contex
|
|||||||
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
|
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id string) (vowifi.SIMMetadata, error) {
|
||||||
|
return mapper.Mapper.ReadSIMMetadata(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
||||||
physical, err := mapper.physical(id)
|
physical, err := mapper.physical(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -175,6 +175,16 @@ USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支
|
|||||||
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
|
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
|
||||||
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。
|
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。
|
||||||
|
|
||||||
|
### QMI 命令行工具
|
||||||
|
|
||||||
|
VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理
|
||||||
|
分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时,
|
||||||
|
Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用
|
||||||
|
`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`。
|
||||||
|
|
||||||
|
`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
|
||||||
|
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
||||||
@@ -195,6 +205,25 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
|||||||
|
|
||||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||||
|
|
||||||
|
## Apple IPCC 运营商规则导入
|
||||||
|
|
||||||
|
VoCat 可以离线解析用户提供的 `.ipcc`,将 Apple 的 XML/二进制 plist
|
||||||
|
转换为可审查的运营商 Profile。默认只预览,不会修改配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vocat carrier import-ipcc Carrier_iPhone.ipcc
|
||||||
|
```
|
||||||
|
|
||||||
|
确认警告和匹配范围后,使用 `--install` 安装;重启 VoCat 后生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vocat carrier import-ipcc --install Carrier_iPhone.ipcc
|
||||||
|
```
|
||||||
|
|
||||||
|
导入器不会复制关闭证书验证、绕过运营商授权、APN 凭据、紧急呼叫或
|
||||||
|
设备型号专属媒体参数。完整字段和冲突处理说明见
|
||||||
|
[CARRIER_IPCC_IMPORT.md](CARRIER_IPCC_IMPORT.md)。
|
||||||
|
|
||||||
## Telegram 机器人
|
## Telegram 机器人
|
||||||
|
|
||||||
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.43.0
|
||||||
|
howett.net/plist v1.0.1
|
||||||
modernc.org/sqlite v1.38.2
|
modernc.org/sqlite v1.38.2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
||||||
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
||||||
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -49,10 +50,13 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
|||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package device
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) {
|
|||||||
return country, len(country) == 2
|
return country, len(country) == 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCCsByCountry returns the complete MCC grouping from the embedded carrier
|
||||||
|
// database, keyed by ISO alpha-2 country/territory code. The returned map and
|
||||||
|
// slices are new values and may be safely modified by callers.
|
||||||
|
func MCCsByCountry() map[string][]string {
|
||||||
|
result := make(map[string][]string)
|
||||||
|
for mcc, rawCountry := range globalCarrierDatabase.Countries {
|
||||||
|
country := strings.ToUpper(strings.TrimSpace(rawCountry))
|
||||||
|
mcc = strings.TrimSpace(mcc)
|
||||||
|
if len(country) != 2 || len(mcc) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[country] = append(result[country], mcc)
|
||||||
|
}
|
||||||
|
for country := range result {
|
||||||
|
sort.Strings(result[country])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
var globalCarrierDatabase = func() carrierDatabase {
|
var globalCarrierDatabase = func() carrierDatabase {
|
||||||
var database carrierDatabase
|
var database carrierDatabase
|
||||||
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) {
|
||||||
|
grouped := MCCsByCountry()
|
||||||
|
if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" {
|
||||||
|
t.Fatalf("GB MCCs = %#v", got)
|
||||||
|
}
|
||||||
|
grouped["GB"][0] = "999"
|
||||||
|
if country, ok := CountryForMCC("234"); !ok || country != "GB" {
|
||||||
|
t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
imsi string
|
imsi string
|
||||||
@@ -110,3 +121,22 @@ func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
|||||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCarrierForSIMRecognizesGiffgaffWithoutRelabelingGenericO2(t *testing.T) {
|
||||||
|
for _, identity := range []CarrierIdentity{
|
||||||
|
{IMSI: "234100000000001", GID1: "508FFFFF", MNCLength: 2},
|
||||||
|
{IMSI: "234100000000001", SPN: "GiffGaff", MNCLength: 2},
|
||||||
|
} {
|
||||||
|
plmn, name, country, ok := CarrierForSIM(identity)
|
||||||
|
if !ok || plmn != "23410" || name != "giffgaff" || country != "GB" {
|
||||||
|
t.Fatalf("giffgaff identity = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, name, _, ok := CarrierForSIM(CarrierIdentity{
|
||||||
|
IMSI: "234100000000001", MNCLength: 2,
|
||||||
|
})
|
||||||
|
if !ok || name == "giffgaff" {
|
||||||
|
t.Fatalf("generic O2 SIM was mislabeled as giffgaff: (%q, %v)", name, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -852,7 +852,13 @@ func decodeUserData(
|
|||||||
message.Text = string(utf16.Decode(units))
|
message.Text = string(utf16.Decode(units))
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
// 8-bit (binary) user data has no portable text representation, so the
|
||||||
|
// raw payload bytes are rendered as uppercase hexadecimal after the user
|
||||||
|
// data header is stripped. This keeps the bubble non-empty and gives a
|
||||||
|
// faithful rendering of the delivered content rather than a blank "".
|
||||||
message.Encoding = SMSEncoding8BitPDU
|
message.Encoding = SMSEncoding8BitPDU
|
||||||
|
payload := data[headerBytes:]
|
||||||
|
message.Text = strings.ToUpper(hex.EncodeToString(payload))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,3 +263,21 @@ func TestParseCMGLPreservesUndecodableRecord(t *testing.T) {
|
|||||||
t.Fatalf("messages = %#v", messages)
|
t.Fatalf("messages = %#v", messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||||
|
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
||||||
|
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
|
||||||
|
// Built from the GSM-7 deliver vector by swapping the DCS to 0xF5
|
||||||
|
// and replacing the user data with three raw binary bytes.
|
||||||
|
message, err := decodeSMSPDU(
|
||||||
|
"000405912143F500F54210203040500003AABBCC",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode 8-bit: %v", err)
|
||||||
|
}
|
||||||
|
if message.Encoding != SMSEncoding8BitPDU ||
|
||||||
|
message.Text != "AABBCC" ||
|
||||||
|
message.RawUserData != "AABBCC" {
|
||||||
|
t.Fatalf("8-bit message = %#v", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-10
@@ -69,7 +69,12 @@ func (manager *Manager) readSnapshot(
|
|||||||
if ccidErr != nil {
|
if ccidErr != nil {
|
||||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||||
}
|
}
|
||||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
|
||||||
|
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
|
||||||
|
// timeout, and every refresh holds the device lock while it does so,
|
||||||
|
// starving the AT terminal. Only fall back to QMI when the AT CPIN
|
||||||
|
// probe already proved a card is present.
|
||||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||||
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
||||||
cancelQMI()
|
cancelQMI()
|
||||||
@@ -186,14 +191,18 @@ func (manager *Manager) readSnapshot(
|
|||||||
snapshot.RegistrationSource = "COPS"
|
snapshot.RegistrationSource = "COPS"
|
||||||
}
|
}
|
||||||
if snapshot.IMEI == "" {
|
if snapshot.IMEI == "" {
|
||||||
response, ok := optional("AT+CGSN")
|
// AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
|
||||||
if ok {
|
// returns the IMEI line but never a final OK, so it would block until the
|
||||||
snapshot.IMEI = parseIdentifier(
|
// caller's deadline (30s during a periodic refresh) and starve every other
|
||||||
response,
|
// device operation behind the lock. Give it an independent short timeout
|
||||||
[]string{"+CGSN:", "+GSN:"},
|
// and let the WWAN transport's drain discard the trailing stale bytes.
|
||||||
14,
|
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
|
||||||
17,
|
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
|
||||||
)
|
cancelCGSN()
|
||||||
|
if cgsnErr == nil {
|
||||||
|
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
|
||||||
|
snapshot.IMEI = imei
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||||
@@ -211,7 +220,6 @@ func (manager *Manager) readSnapshot(
|
|||||||
// Preserve a prior successful read across a transient QMI/AT failure.
|
// Preserve a prior successful read across a transient QMI/AT failure.
|
||||||
snapshot.IMEI = previousSnapshot.IMEI
|
snapshot.IMEI = previousSnapshot.IMEI
|
||||||
}
|
}
|
||||||
|
|
||||||
if response, ok := optional("AT+CFUN?"); ok {
|
if response, ok := optional("AT+CFUN?"); ok {
|
||||||
if mode, found := parseCFUN(response); found {
|
if mode, found := parseCFUN(response); found {
|
||||||
snapshot.OperatingMode = mode
|
snapshot.OperatingMode = mode
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/modem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lenientATClient answers every command with a bare CommandError and records
|
||||||
|
// the commands it saw. It lets snapshot tests exercise the full readSnapshot
|
||||||
|
// sequence without enumerating every step of the transcript.
|
||||||
|
type lenientATClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
commands []string
|
||||||
|
cgsnDelay time.Duration
|
||||||
|
cgsnIMEI string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) Execute(ctx context.Context, command string) (modem.Response, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.commands = append(c.commands, command)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if command == "ATI" {
|
||||||
|
return okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410"), nil
|
||||||
|
}
|
||||||
|
if command == "AT+CGSN" && c.cgsnDelay > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(c.cgsnDelay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if command == "AT+CGSN" && c.cgsnIMEI != "" {
|
||||||
|
return okResponse("+CGSN: " + c.cgsnIMEI), nil
|
||||||
|
}
|
||||||
|
return modem.Response{}, &modem.CommandError{Command: command, Final: "ERROR"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) WaitURC(context.Context, func(string) bool) (string, error) {
|
||||||
|
return "", errors.New("no URC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) Close() error { return nil }
|
||||||
|
|
||||||
|
func (c *lenientATClient) saw(command string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for _, seen := range c.commands {
|
||||||
|
if seen == command {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT+CGSN on some MHI modems returns the IMEI line but never a final OK, so it
|
||||||
|
// would block until the caller's deadline and hold the device lock for the
|
||||||
|
// whole periodic refresh. The snapshot must bound CGSN with its own short
|
||||||
|
// timeout instead of inheriting the refresh deadline.
|
||||||
|
func TestManagerRefreshBoundsCGSNTimeout(t *testing.T) {
|
||||||
|
client := &lenientATClient{cgsnDelay: 5 * time.Second}
|
||||||
|
manager, id := newStartedTestManager(t, client)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
snapshot, err := manager.Refresh(ctx, id)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
// CGSN times out after CommandTimeout (1s in the test manager); the rest
|
||||||
|
// of the snapshot is immediate. An un-bounded CGSN would wait for the
|
||||||
|
// 4s outer deadline (or worse, a real 30s refresh deadline).
|
||||||
|
if elapsed > 3*time.Second {
|
||||||
|
t.Fatalf("Refresh took %s; CGSN was not bounded by CommandTimeout", elapsed)
|
||||||
|
}
|
||||||
|
if !client.saw("AT+CGSN") {
|
||||||
|
t.Fatalf("CGSN was never sent; commands = %v", client.commands)
|
||||||
|
}
|
||||||
|
if snapshot.IMEI != "" {
|
||||||
|
t.Fatalf("IMEI = %q, want empty after CGSN timeout", snapshot.IMEI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing SIM must not fall back to the QMI UIM ICCID read: without a READY
|
||||||
|
// card that call blocks until its long timeout and starves the AT terminal
|
||||||
|
// behind the device lock.
|
||||||
|
func TestManagerRefreshSkipsQMIICCIDWithoutReadySIM(t *testing.T) {
|
||||||
|
// CGSN succeeds so the snapshot does not fall back to the QMI DMS IMEI
|
||||||
|
// read either; the test focuses on the UIM ICCID fallback being skipped
|
||||||
|
// without a READY card.
|
||||||
|
client := &lenientATClient{cgsnIMEI: "866241014372802"}
|
||||||
|
manager, err := NewManager(Options{
|
||||||
|
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
|
||||||
|
ID: "mhi-wwan0",
|
||||||
|
Product: "PCIe/MHI WWAN modem",
|
||||||
|
QMIControl: "/dev/wwan0qmi0",
|
||||||
|
NetworkInterface: "wwan0",
|
||||||
|
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
|
||||||
|
}}},
|
||||||
|
Opener: &staticOpener{client: client},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := manager.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||||
|
|
||||||
|
qmiCalls := 0
|
||||||
|
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||||
|
qmiCalls++
|
||||||
|
return nil, errors.New("QMI should not be opened without a SIM")
|
||||||
|
}
|
||||||
|
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
// Exactly one QMI open is expected: the immutable DMS IMEI read runs
|
||||||
|
// unconditionally for native QMI candidates (IMEI is hardware identity,
|
||||||
|
// independent of the card). The UIM ICCID fallback, which would block
|
||||||
|
// without a READY SIM, must be skipped.
|
||||||
|
if qmiCalls != 1 {
|
||||||
|
t.Fatalf("qmiRadioOpener called %d times, want 1 (DMS IMEI only, UIM ICCID must be skipped without a READY SIM)", qmiCalls)
|
||||||
|
}
|
||||||
|
for _, warning := range snapshot.Warnings {
|
||||||
|
if strings.Contains(warning, "QMI UIM") {
|
||||||
|
t.Fatalf("unexpected QMI ICCID warning: %q", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
// Keep feature-specific diagnostic strings together so additions to the proxy
|
||||||
|
// probe do not cause conflicts in the shared dictionary.
|
||||||
|
func init() {
|
||||||
|
zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall."
|
||||||
|
zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed."
|
||||||
|
zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed."
|
||||||
|
zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed."
|
||||||
|
}
|
||||||
+78
-10
@@ -11,8 +11,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const quectelVendorID = "2c7c"
|
const (
|
||||||
|
djiVendorID = "2ca3"
|
||||||
|
dji4GProductID = "4006"
|
||||||
|
)
|
||||||
type SysFSDiscoverer struct {
|
type SysFSDiscoverer struct {
|
||||||
SysRoot string
|
SysRoot string
|
||||||
DevRoot string
|
DevRoot string
|
||||||
@@ -40,10 +42,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
entries = nil
|
entries = nil
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
return nil, fmt.Errorf("discover USB QMI modems: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Candidate modems are identified by kernel driver binding instead of a
|
||||||
|
// vendor-ID whitelist. qmi_wwan only binds Qualcomm QMI control interfaces,
|
||||||
|
// so any USB device with a bound interface exposes a live QMI channel. This
|
||||||
|
// keeps discovery vendor-neutral (SIMCom, Sierra, Telit and other
|
||||||
|
// Qualcomm-based modems are found automatically) while MBIM-only devices
|
||||||
|
// stay out, because cdc_mbim binds their control interface instead and the
|
||||||
|
// project has no MBIM backend.
|
||||||
|
qmiBound := d.qmiWWANBoundDevices()
|
||||||
|
|
||||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||||
devices := make(map[string]*discoveredUSBDevice)
|
devices := make(map[string]*discoveredUSBDevice)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -70,17 +81,17 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
resolvedDevice = devicePath
|
resolvedDevice = devicePath
|
||||||
}
|
}
|
||||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||||
if vendorID != quectelVendorID {
|
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||||
|
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
state := devices[deviceName]
|
state := devices[deviceName]
|
||||||
if state == nil {
|
if state == nil {
|
||||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
|
||||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||||
state = &discoveredUSBDevice{
|
state = &discoveredUSBDevice{
|
||||||
candidate: Candidate{
|
candidate: Candidate{
|
||||||
ID: candidateID(productID, serialNumber, deviceName),
|
ID: candidateID(vendorID, productID, serialNumber, deviceName),
|
||||||
VendorID: vendorID,
|
VendorID: vendorID,
|
||||||
ProductID: productID,
|
ProductID: productID,
|
||||||
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
||||||
@@ -130,6 +141,14 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
})
|
})
|
||||||
assignQuectelPortRoles(state.candidate.Ports)
|
assignQuectelPortRoles(state.candidate.Ports)
|
||||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||||
|
if !state.candidate.HasATPort() {
|
||||||
|
// A bound QMI interface proves the modem is alive, but the snapshot,
|
||||||
|
// SMS, USSD and eSIM (AT+CSIM) paths all require an AT port. A missing
|
||||||
|
// ttyUSB/ttyACM node almost always means the option/qcserial driver
|
||||||
|
// does not claim the serial interfaces (often a missing PID in its
|
||||||
|
// device-ID table), not that the module lacks an AT interface.
|
||||||
|
state.candidate.DiscoveryIssue = "at_port_missing"
|
||||||
|
}
|
||||||
result = append(result, state.candidate)
|
result = append(result, state.candidate)
|
||||||
}
|
}
|
||||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||||
@@ -141,6 +160,14 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
||||||
|
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
||||||
|
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
||||||
|
func IsDJI4GUSB(vendorID, productID string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(vendorID), djiVendorID) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
||||||
|
}
|
||||||
|
|
||||||
type discoveredWWANDevice struct {
|
type discoveredWWANDevice struct {
|
||||||
index string
|
index string
|
||||||
ports []Port
|
ports []Port
|
||||||
@@ -235,7 +262,7 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
|||||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||||
}
|
}
|
||||||
if len(group.ports) > 0 {
|
if len(group.ports) > 0 {
|
||||||
candidate.ATPort = group.ports[0]
|
candidate.ATPort = selectWWANATPort(group.ports)
|
||||||
}
|
}
|
||||||
if len(group.qmiNames) > 0 {
|
if len(group.qmiNames) > 0 {
|
||||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||||
@@ -246,6 +273,20 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectWWANATPort prefers the secondary AT port (…at1) over the primary
|
||||||
|
// (…at0) when both exist, falling back to the first AT port otherwise. Some
|
||||||
|
// Qualcomm MHI modems (notably the UFI dongle behind the OpenStick 410) answer
|
||||||
|
// on at1 immediately while at0 delays every response by 10-20 seconds, so the
|
||||||
|
// secondary port is the usable AT channel.
|
||||||
|
func selectWWANATPort(ports []Port) Port {
|
||||||
|
for _, port := range ports {
|
||||||
|
if port.InterfaceNumber == 1 {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports[0]
|
||||||
|
}
|
||||||
|
|
||||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||||
if !strings.HasPrefix(name, "wwan") {
|
if !strings.HasPrefix(name, "wwan") {
|
||||||
return "", "", 0, false
|
return "", "", 0, false
|
||||||
@@ -385,7 +426,34 @@ func readSerialAliases(root string) map[string]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func candidateID(productID, serialNumber, usbName string) string {
|
// qmiWWANBoundDevices returns the set of USB device paths (for example "1-6"
|
||||||
|
// or the hub-attached "1-4.3.2") that currently have at least one interface
|
||||||
|
// bound to the kernel's qmi_wwan driver. Interface entries in the driver
|
||||||
|
// directory are named "<device-path>:<interface>.<altsetting>", so the part
|
||||||
|
// before the first colon is the owning USB device. The qmi_wwan driver only
|
||||||
|
// binds Qualcomm QMI control interfaces, so membership doubles as a vendor-
|
||||||
|
// neutral "this is a live QMI modem" signal.
|
||||||
|
func (d *SysFSDiscoverer) qmiWWANBoundDevices() map[string]struct{} {
|
||||||
|
driverRoot := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||||
|
entries, err := os.ReadDir(driverRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
devices := make(map[string]struct{})
|
||||||
|
for _, entry := range entries {
|
||||||
|
// The driver directory also holds control files (bind, unbind, uevent,
|
||||||
|
// module, new_id, ...); only names containing a colon are interfaces.
|
||||||
|
deviceName, _, ok := strings.Cut(entry.Name(), ":")
|
||||||
|
if !ok || deviceName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices[deviceName] = struct{}{}
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
||||||
|
prefix := "usb-" + sanitizeID(vendorID)
|
||||||
serialNumber = strings.TrimSpace(serialNumber)
|
serialNumber = strings.TrimSpace(serialNumber)
|
||||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||||
@@ -394,9 +462,9 @@ func candidateID(productID, serialNumber, usbName string) string {
|
|||||||
// to the same hub into one entry. Include the physical USB topology in the
|
// to the same hub into one entry. Include the physical USB topology in the
|
||||||
// discovery key; configured devices remain stable through ATMapper's
|
// discovery key; configured devices remain stable through ATMapper's
|
||||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||||
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
||||||
}
|
}
|
||||||
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
return prefix + "-" + sanitizeID(productID+"-"+usbName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeID(value string) string {
|
func sanitizeID(value string) string {
|
||||||
|
|||||||
@@ -2,6 +2,29 @@ package modem
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsDJI4GUSBIdentity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
vendorID string
|
||||||
|
productID string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
||||||
|
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
||||||
|
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
||||||
|
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
|
||||||
|
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
|
||||||
|
t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
||||||
ports := []Port{
|
ports := []Port{
|
||||||
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||||
|
|
||||||
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
||||||
candidates, err := discoverer.Discover(context.Background())
|
candidates, err := discoverer.Discover(context.Background())
|
||||||
@@ -57,7 +59,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
}
|
}
|
||||||
candidate := candidates[0]
|
candidate := candidates[0]
|
||||||
if candidate.ID != "quectel-0125-1-6" {
|
if candidate.ID != "usb-2c7c-0125-1-6" {
|
||||||
t.Fatalf("ID = %q", candidate.ID)
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Name != "ttyUSB2" {
|
if candidate.ATPort.Name != "ttyUSB2" {
|
||||||
@@ -101,6 +103,7 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
|||||||
)
|
)
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,6 +149,7 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, modem.usbName+":1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -194,6 +198,7 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, item.usbName+":1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -216,9 +221,11 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||||
|
// A plain USB serial adapter (FTDI) exposes ttyUSB but no QMI interface and
|
||||||
|
// is never bound to qmi_wwan, so it must not be treated as a modem.
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
||||||
@@ -235,6 +242,117 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryFindsNonQuectelVendorBoundToQMIWWAN(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// A Sierra EM7430 flashed to its QMI (rmnet0) composition: non-Quectel
|
||||||
|
// vendor, but its control interface is bound to qmi_wwan.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "idVendor"), "1199\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "idProduct"), "9077\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "manufacturer"), "Sierra Wireless, Incorporated\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "product"), "EM7430\n")
|
||||||
|
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||||
|
interfaceName := "1-3:1." + strconv.Itoa(number)
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
|
}
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-3:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-3:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.VendorID != "1199" || candidate.Product != "EM7430" {
|
||||||
|
t.Fatalf("candidate = %#v", candidate)
|
||||||
|
}
|
||||||
|
if candidate.ID != "usb-1199-9077-1-3" {
|
||||||
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
|
}
|
||||||
|
if candidate.ATPort.Role != PortRoleAT {
|
||||||
|
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
if candidate.DiscoveryIssue != "" {
|
||||||
|
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryMarksQMIModemWithoutATPort(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// QMI control interface is bound, but no ttyUSB/ttyACM node exists (for
|
||||||
|
// example the option/qcserial driver does not claim the serial interfaces).
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-7", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-7", "idProduct"), "0125\n")
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-7:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-7:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||||
|
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
if candidate.HasATPort() {
|
||||||
|
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryFindsHubAttachedQMIWWANDevice(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// Device behind a hub: the USB path "1-4.3.2" contains extra segments, and
|
||||||
|
// the qmi_wwan binding uses the same composite path before the colon.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idProduct"), "0125\n")
|
||||||
|
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||||
|
interfaceName := "1-4.3.2:1." + strconv.Itoa(number)
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
|
}
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-4.3.2:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-4.3.2:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.ATPort.Name != "ttyUSB2" {
|
||||||
|
t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
if !strings.Contains(candidate.ID, "1-4-3-2") {
|
||||||
|
t.Fatalf("ID = %q, want hub topology in discovery key", candidate.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
sysRoot := filepath.Join(root, "sys")
|
sysRoot := filepath.Join(root, "sys")
|
||||||
@@ -256,7 +374,7 @@ func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
|||||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||||
t.Fatalf("identity = %#v", candidate)
|
t.Fatalf("identity = %#v", candidate)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at1") || candidate.ATPort.Role != PortRoleAT {
|
||||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||||
}
|
}
|
||||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||||
@@ -290,6 +408,23 @@ func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectWWANATPortPrefersSecondaryATPort(t *testing.T) {
|
||||||
|
ports := []Port{
|
||||||
|
{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT},
|
||||||
|
{Name: "wwan0at1", InterfaceNumber: 1, Role: PortRoleAT},
|
||||||
|
}
|
||||||
|
if got := selectWWANATPort(ports); got.Name != "wwan0at1" {
|
||||||
|
t.Fatalf("selectWWANATPort = %#v, want wwan0at1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectWWANATPortFallsBackToPrimaryWhenOnlyAT0(t *testing.T) {
|
||||||
|
ports := []Port{{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT}}
|
||||||
|
if got := selectWWANATPort(ports); got.Name != "wwan0at0" {
|
||||||
|
t.Fatalf("selectWWANATPort = %#v, want wwan0at0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseWWANPortName(t *testing.T) {
|
func TestParseWWANPortName(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name, index, kind string
|
name, index, kind string
|
||||||
@@ -323,3 +458,18 @@ func mustMkdir(t *testing.T, path string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mustBindQMIWWAN mimics the kernel's driver-binding directory entry: it adds
|
||||||
|
// interfaceName (e.g. "1-6:1.4") under /sys/bus/usb/drivers/qmi_wwan exactly
|
||||||
|
// like the real qmi_wwan driver directory does for a bound QMI interface.
|
||||||
|
func mustBindQMIWWAN(t *testing.T, sysRoot, interfaceName string) {
|
||||||
|
t.Helper()
|
||||||
|
driverDir := filepath.Join(sysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||||
|
if err := os.MkdirAll(driverDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(sysRoot, "bus", "usb", "devices", interfaceName)
|
||||||
|
if err := os.Symlink(target, filepath.Join(driverDir, interfaceName)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,14 +148,19 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
|
// 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 {
|
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := session.transport.Drain(); err != nil {
|
|
||||||
session.poisonLocked()
|
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
|
||||||
}
|
|
||||||
return session.readFinalLocked(ctx, started, command, "", response)
|
return session.readFinalLocked(ctx, started, command, "", response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +184,7 @@ func (session *Session) executePromptLocked(
|
|||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := session.transport.Drain(); err != nil {
|
if err := drainTransport(ctx, session.transport); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +209,7 @@ func (session *Session) executePromptLocked(
|
|||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := session.transport.Drain(); err != nil {
|
if err := drainTransport(ctx, session.transport); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
||||||
@@ -211,6 +217,21 @@ func (session *Session) executePromptLocked(
|
|||||||
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainTransport retries tcdrain/TCSBRK when the kernel interrupts it with a
|
||||||
|
// signal. go.bug.st/serial already retries EINTR for Read, but its Linux
|
||||||
|
// Drain implementation currently returns the transient error directly.
|
||||||
|
func drainTransport(ctx context.Context, transport Transport) error {
|
||||||
|
for {
|
||||||
|
err := transport.Drain()
|
||||||
|
if !errors.Is(err, syscall.EINTR) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) readFinalLocked(
|
func (session *Session) readFinalLocked(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
started time.Time,
|
started time.Time,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,8 @@ type transcriptTransport struct {
|
|||||||
unexpected error
|
unexpected error
|
||||||
writePartial bool
|
writePartial bool
|
||||||
writeEvents chan string
|
writeEvents chan string
|
||||||
|
drainErrors []error
|
||||||
|
drainCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
||||||
@@ -114,7 +117,17 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) {
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) Drain() error { return nil }
|
func (transport *transcriptTransport) Drain() error {
|
||||||
|
transport.mu.Lock()
|
||||||
|
defer transport.mu.Unlock()
|
||||||
|
transport.drainCount++
|
||||||
|
if len(transport.drainErrors) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := transport.drainErrors[0]
|
||||||
|
transport.drainErrors = transport.drainErrors[1:]
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) ResetInputBuffer() error {
|
func (transport *transcriptTransport) ResetInputBuffer() error {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
@@ -138,6 +151,31 @@ func (transport *transcriptTransport) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionRetriesInterruptedDrain(t *testing.T) {
|
||||||
|
transport := &transcriptTransport{
|
||||||
|
steps: []transportStep{{
|
||||||
|
write: "AT+CSQ\r",
|
||||||
|
chunks: []string{"\r\nAT+CSQ\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||||
|
}},
|
||||||
|
drainErrors: []error{syscall.EINTR},
|
||||||
|
}
|
||||||
|
session, err := NewSession(transport, SessionOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSession() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := session.Execute(context.Background(), "AT+CSQ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if response.Final != "OK" {
|
||||||
|
t.Fatalf("response final = %q", response.Final)
|
||||||
|
}
|
||||||
|
if transport.drainCount != 2 {
|
||||||
|
t.Fatalf("Drain() calls = %d, want 2", transport.drainCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
||||||
transport := &transcriptTransport{steps: []transportStep{{
|
transport := &transcriptTransport{steps: []transportStep{{
|
||||||
write: "AT+CSQ\r",
|
write: "AT+CSQ\r",
|
||||||
@@ -415,6 +453,62 @@ func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainOrderTransport forwards to an inner Transport while recording
|
||||||
|
// transport-level events, so a test can assert the exact order of Drain and
|
||||||
|
// Write calls.
|
||||||
|
type drainOrderTransport struct {
|
||||||
|
inner Transport
|
||||||
|
events chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Write(payload []byte) (int, error) {
|
||||||
|
transport.events <- "write:" + string(payload)
|
||||||
|
return transport.inner.Write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Read(buffer []byte) (int, error) {
|
||||||
|
return transport.inner.Read(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Drain() error {
|
||||||
|
transport.events <- "drain"
|
||||||
|
return transport.inner.Drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) ResetInputBuffer() error {
|
||||||
|
return transport.inner.ResetInputBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) SetReadTimeout(timeout time.Duration) error {
|
||||||
|
return transport.inner.SetReadTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Close() error {
|
||||||
|
return transport.inner.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WWAN transports discard stale bytes left over from a timed-out command
|
||||||
|
// inside Drain, so the session must call it before writing the next command;
|
||||||
|
// otherwise a late reply (e.g. a slow CGSN response) would be mis-parsed as
|
||||||
|
// the new command's output.
|
||||||
|
func TestSessionDrainsBeforeWritingCommand(t *testing.T) {
|
||||||
|
inner := &transcriptTransport{steps: []transportStep{{
|
||||||
|
write: "AT+CSQ\r",
|
||||||
|
chunks: []string{"\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||||
|
}}}
|
||||||
|
events := make(chan string, 8)
|
||||||
|
session := newTestSession(t, &drainOrderTransport{inner: inner, events: events})
|
||||||
|
if _, err := session.Execute(context.Background(), "AT+CSQ"); err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if first := <-events; first != "drain" {
|
||||||
|
t.Fatalf("first transport event = %q, want drain before the command write", first)
|
||||||
|
}
|
||||||
|
if second := <-events; second != "write:AT+CSQ\r" {
|
||||||
|
t.Fatalf("second transport event = %q, want the command write", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestSession(t *testing.T, transport Transport) *Session {
|
func newTestSession(t *testing.T, transport Transport) *Session {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
session, err := NewSession(transport, SessionOptions{
|
session, err := NewSession(transport, SessionOptions{
|
||||||
|
|||||||
@@ -108,8 +108,27 @@ func (transport *nativeWWANATTransport) Drain() error {
|
|||||||
return io.ErrClosedPipe
|
return io.ErrClosedPipe
|
||||||
}
|
}
|
||||||
// WWAN character-device writes are handed to the modem synchronously and
|
// WWAN character-device writes are handed to the modem synchronously and
|
||||||
// have no termios output queue to drain.
|
// have no termios output queue to drain. A previous command that timed out
|
||||||
return nil
|
// 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 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
-1
@@ -3,6 +3,8 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -17,18 +19,44 @@ type ProbeResult struct {
|
|||||||
Reachable bool `json:"reachable"`
|
Reachable bool `json:"reachable"`
|
||||||
HandshakeOK bool `json:"handshake_ok"`
|
HandshakeOK bool `json:"handshake_ok"`
|
||||||
UDPAssociateOK bool `json:"udp_associate_ok"`
|
UDPAssociateOK bool `json:"udp_associate_ok"`
|
||||||
|
UDPExchangeOK bool `json:"udp_exchange_ok"`
|
||||||
AuthMethod string `json:"auth_method,omitempty"`
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
RelayAddr string `json:"relay_addr,omitempty"`
|
RelayAddr string `json:"relay_addr,omitempty"`
|
||||||
|
DNSServer string `json:"dns_server,omitempty"`
|
||||||
|
DNSName string `json:"dns_name,omitempty"`
|
||||||
|
DNSRCode int `json:"dns_rcode,omitempty"`
|
||||||
|
RoundTripMS int64 `json:"round_trip_ms,omitempty"`
|
||||||
Diagnosis string `json:"diagnosis,omitempty"`
|
Diagnosis string `json:"diagnosis,omitempty"`
|
||||||
Hint string `json:"hint,omitempty"`
|
Hint string `json:"hint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultProbeDNSServer = "1.1.1.1:53"
|
||||||
|
defaultProbeDNSName = "example.com"
|
||||||
|
)
|
||||||
|
|
||||||
func ProbeSOCKS5(
|
func ProbeSOCKS5(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
address string,
|
address string,
|
||||||
username string,
|
username string,
|
||||||
password string,
|
password string,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
|
) (ProbeResult, error) {
|
||||||
|
return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real
|
||||||
|
// UDP DNS round trip through the returned relay. Keeping the target injectable
|
||||||
|
// makes the negative paths deterministic in tests without weakening the
|
||||||
|
// production probe.
|
||||||
|
func probeSOCKS5(
|
||||||
|
ctx context.Context,
|
||||||
|
address string,
|
||||||
|
username string,
|
||||||
|
password string,
|
||||||
|
timeout time.Duration,
|
||||||
|
dnsServer string,
|
||||||
|
dnsName string,
|
||||||
) (ProbeResult, error) {
|
) (ProbeResult, error) {
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||||
@@ -122,11 +150,228 @@ func ProbeSOCKS5(
|
|||||||
port := int(portBytes[0])<<8 | int(portBytes[1])
|
port := int(portBytes[0])<<8 | int(portBytes[1])
|
||||||
result.UDPAssociateOK = true
|
result.UDPAssociateOK = true
|
||||||
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||||
|
result.DNSServer = dnsServer
|
||||||
|
result.DNSName = dnsName
|
||||||
|
|
||||||
|
if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil {
|
||||||
|
if result.Diagnosis == "" {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
}
|
||||||
|
if result.Hint == "" {
|
||||||
|
result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。")
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
result.Diagnosis = "ready"
|
result.Diagnosis = "ready"
|
||||||
result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。")
|
result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。")
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func probeUDPExchange(
|
||||||
|
ctx context.Context,
|
||||||
|
control net.Conn,
|
||||||
|
result *ProbeResult,
|
||||||
|
relayHost string,
|
||||||
|
relayPort int,
|
||||||
|
dnsServer string,
|
||||||
|
dnsName string,
|
||||||
|
timeout time.Duration,
|
||||||
|
) error {
|
||||||
|
if result == nil {
|
||||||
|
return errors.New("proxy: probe result is nil")
|
||||||
|
}
|
||||||
|
dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer))
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_target"
|
||||||
|
return fmt.Errorf("proxy: resolve UDP probe target: %w", err)
|
||||||
|
}
|
||||||
|
relayHost = strings.TrimSpace(relayHost)
|
||||||
|
if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() {
|
||||||
|
remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String())
|
||||||
|
if splitErr != nil {
|
||||||
|
result.Diagnosis = "invalid_udp_relay"
|
||||||
|
return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr)
|
||||||
|
}
|
||||||
|
relayHost = remoteHost
|
||||||
|
}
|
||||||
|
relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort)))
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_udp_relay"
|
||||||
|
return fmt.Errorf("proxy: resolve UDP relay: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localNetwork := "udp4"
|
||||||
|
if relayAddress.IP != nil && relayAddress.IP.To4() == nil {
|
||||||
|
localNetwork = "udp6"
|
||||||
|
}
|
||||||
|
udpConnection, err := net.ListenUDP(localNetwork, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_socket_failed"
|
||||||
|
return fmt.Errorf("proxy: open UDP probe socket: %w", err)
|
||||||
|
}
|
||||||
|
defer udpConnection.Close()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||||
|
deadline = contextDeadline
|
||||||
|
}
|
||||||
|
if err := udpConnection.SetDeadline(deadline); err != nil {
|
||||||
|
return fmt.Errorf("proxy: set UDP probe deadline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query, queryID, err := buildDNSQuery(dnsName)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_name"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
datagram, err := buildSOCKSUDPDatagram(dnsAddress, query)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_target"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
startedAt := time.Now()
|
||||||
|
if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil {
|
||||||
|
result.Diagnosis = "udp_send_failed"
|
||||||
|
return fmt.Errorf("proxy: send UDP DNS probe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBuffer := make([]byte, 64*1024)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err)
|
||||||
|
}
|
||||||
|
count, sender, err := udpConnection.ReadFromUDP(responseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err)
|
||||||
|
}
|
||||||
|
if !sameUDPAddress(sender, relayAddress) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, err := parseSOCKSUDPDatagram(responseBuffer[:count])
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_invalid_response"
|
||||||
|
return fmt.Errorf("proxy: parse UDP relay response: %w", err)
|
||||||
|
}
|
||||||
|
rcode, err := validateDNSResponse(payload, queryID)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "dns_invalid_response"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.UDPExchangeOK = true
|
||||||
|
result.DNSRCode = rcode
|
||||||
|
result.RoundTripMS = time.Since(startedAt).Milliseconds()
|
||||||
|
if result.RoundTripMS < 1 {
|
||||||
|
result.RoundTripMS = 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDNSQuery(name string) ([]byte, uint16, error) {
|
||||||
|
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||||
|
if name == "" || len(name) > 253 {
|
||||||
|
return nil, 0, errors.New("proxy: UDP probe DNS name is invalid")
|
||||||
|
}
|
||||||
|
var idBytes [2]byte
|
||||||
|
if _, err := rand.Read(idBytes[:]); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err)
|
||||||
|
}
|
||||||
|
queryID := binary.BigEndian.Uint16(idBytes[:])
|
||||||
|
query := make([]byte, 12, 12+len(name)+6)
|
||||||
|
binary.BigEndian.PutUint16(query[0:2], queryID)
|
||||||
|
binary.BigEndian.PutUint16(query[2:4], 0x0100)
|
||||||
|
binary.BigEndian.PutUint16(query[4:6], 1)
|
||||||
|
for _, label := range strings.Split(name, ".") {
|
||||||
|
if label == "" || len(label) > 63 {
|
||||||
|
return nil, 0, errors.New("proxy: UDP probe DNS label is invalid")
|
||||||
|
}
|
||||||
|
query = append(query, byte(len(label)))
|
||||||
|
query = append(query, label...)
|
||||||
|
}
|
||||||
|
query = append(query, 0, 0, 1, 0, 1)
|
||||||
|
return query, queryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||||
|
if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 {
|
||||||
|
return nil, errors.New("proxy: UDP target is invalid")
|
||||||
|
}
|
||||||
|
packet := []byte{0, 0, 0}
|
||||||
|
if ipv4 := target.IP.To4(); ipv4 != nil {
|
||||||
|
packet = append(packet, 1)
|
||||||
|
packet = append(packet, ipv4...)
|
||||||
|
} else if ipv6 := target.IP.To16(); ipv6 != nil {
|
||||||
|
packet = append(packet, 4)
|
||||||
|
packet = append(packet, ipv6...)
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("proxy: UDP target address family is invalid")
|
||||||
|
}
|
||||||
|
packet = append(packet, byte(target.Port>>8), byte(target.Port))
|
||||||
|
packet = append(packet, payload...)
|
||||||
|
return packet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) {
|
||||||
|
if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 {
|
||||||
|
return nil, errors.New("invalid SOCKS5 UDP header")
|
||||||
|
}
|
||||||
|
if packet[2] != 0 {
|
||||||
|
return nil, errors.New("fragmented SOCKS5 UDP response is unsupported")
|
||||||
|
}
|
||||||
|
offset := 4
|
||||||
|
switch packet[3] {
|
||||||
|
case 1:
|
||||||
|
offset += net.IPv4len
|
||||||
|
case 3:
|
||||||
|
if len(packet) <= offset {
|
||||||
|
return nil, errors.New("truncated SOCKS5 UDP domain")
|
||||||
|
}
|
||||||
|
offset += 1 + int(packet[offset])
|
||||||
|
case 4:
|
||||||
|
offset += net.IPv6len
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported SOCKS5 UDP address type")
|
||||||
|
}
|
||||||
|
if offset+2 > len(packet) {
|
||||||
|
return nil, errors.New("truncated SOCKS5 UDP endpoint")
|
||||||
|
}
|
||||||
|
offset += 2
|
||||||
|
if offset >= len(packet) {
|
||||||
|
return nil, errors.New("empty SOCKS5 UDP payload")
|
||||||
|
}
|
||||||
|
return packet[offset:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDNSResponse(payload []byte, queryID uint16) (int, error) {
|
||||||
|
if len(payload) < 12 {
|
||||||
|
return 0, errors.New("proxy: DNS response is truncated")
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint16(payload[0:2]) != queryID {
|
||||||
|
return 0, errors.New("proxy: DNS response ID does not match")
|
||||||
|
}
|
||||||
|
flags := binary.BigEndian.Uint16(payload[2:4])
|
||||||
|
if flags&0x8000 == 0 {
|
||||||
|
return 0, errors.New("proxy: DNS response is not a response")
|
||||||
|
}
|
||||||
|
rcode := int(flags & 0x000f)
|
||||||
|
if rcode != 0 {
|
||||||
|
return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode)
|
||||||
|
}
|
||||||
|
return rcode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameUDPAddress(left, right *net.UDPAddr) bool {
|
||||||
|
if left == nil || right == nil || left.Port != right.Port {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left.IP == nil || right.IP == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return left.IP.Equal(right.IP)
|
||||||
|
}
|
||||||
|
|
||||||
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
||||||
switch addressType {
|
switch addressType {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) {
|
||||||
|
address, stop := startProbeSOCKS5Server(t, false)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
result, err := probeSOCKS5(
|
||||||
|
context.Background(),
|
||||||
|
address,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
250*time.Millisecond,
|
||||||
|
"192.0.2.53:53",
|
||||||
|
"example.test",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data")
|
||||||
|
}
|
||||||
|
if !result.UDPAssociateOK {
|
||||||
|
t.Fatal("UDP ASSOCIATE should have succeeded")
|
||||||
|
}
|
||||||
|
if result.UDPExchangeOK {
|
||||||
|
t.Fatal("UDP exchange should not be reported as successful")
|
||||||
|
}
|
||||||
|
if result.Diagnosis != "udp_no_roundtrip" {
|
||||||
|
t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) {
|
||||||
|
address, stop := startProbeSOCKS5Server(t, true)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
result, err := probeSOCKS5(
|
||||||
|
context.Background(),
|
||||||
|
address,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
time.Second,
|
||||||
|
"192.0.2.53:53",
|
||||||
|
"example.test",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Probe returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK {
|
||||||
|
t.Fatalf("Probe evidence incomplete: %+v", result)
|
||||||
|
}
|
||||||
|
if result.Diagnosis != "ready" {
|
||||||
|
t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis)
|
||||||
|
}
|
||||||
|
if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" {
|
||||||
|
t.Fatalf("Unexpected DNS evidence: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListenUDP: %v", err)
|
||||||
|
}
|
||||||
|
tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
udpConnection.Close()
|
||||||
|
t.Fatalf("Listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if echoDNS {
|
||||||
|
go func() {
|
||||||
|
buffer := make([]byte, 2048)
|
||||||
|
count, sender, readErr := udpConnection.ReadFromUDP(buffer)
|
||||||
|
if readErr != nil || count < 22 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The test target is IPv4, so the SOCKS5 UDP header is ten bytes.
|
||||||
|
buffer[12] = 0x81
|
||||||
|
buffer[13] = 0x80
|
||||||
|
_, _ = udpConnection.WriteToUDP(buffer[:count], sender)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
connection, acceptErr := tcpListener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
greeting := make([]byte, 3)
|
||||||
|
if _, readErr := io.ReadFull(connection, greeting); readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
associate := make([]byte, 10)
|
||||||
|
if _, readErr := io.ReadFull(connection, associate); readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)}
|
||||||
|
if _, writeErr := connection.Write(response); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, connection)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return tcpListener.Addr().String(), func() {
|
||||||
|
_ = tcpListener.Close()
|
||||||
|
_ = udpConnection.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/modem"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -44,3 +53,62 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The AT terminal must present ERROR / +CME ERROR as a normal response, not as
|
||||||
|
// a 502. Before the CommandError branch was restored, every unsupported or
|
||||||
|
// SIM-less command was folded into "the device operation failed", hiding the
|
||||||
|
// real reason from the user.
|
||||||
|
func TestHandleATSurfacesCommandErrorAsResponse(t *testing.T) {
|
||||||
|
controller := fakeDeviceController{
|
||||||
|
entry: device.Device{ID: "dev1"},
|
||||||
|
atHandler: func(command string) (modem.Response, error) {
|
||||||
|
return modem.Response{}, &modem.CommandError{
|
||||||
|
Command: command,
|
||||||
|
Final: "+CME ERROR: 10",
|
||||||
|
Lines: []string{"+CME ERROR: 10"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/devices/dev1/actions/at",
|
||||||
|
strings.NewReader(`{"cmd":"AT+CPIN?","timeout_ms":5000}`),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !server.handleAT(recorder, request, "dev1") {
|
||||||
|
t.Fatal("handleAT returned false")
|
||||||
|
}
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
data := decodeData(t, recorder)
|
||||||
|
response, _ := data["response"].(string)
|
||||||
|
if !strings.Contains(response, "+CME ERROR: 10") {
|
||||||
|
t.Fatalf("response = %q, want +CME ERROR text", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleATMapsNonCommandErrorTo502(t *testing.T) {
|
||||||
|
controller := fakeDeviceController{
|
||||||
|
entry: device.Device{ID: "dev1"},
|
||||||
|
atErr: errors.New("transport wedged"),
|
||||||
|
}
|
||||||
|
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/devices/dev1/actions/at",
|
||||||
|
strings.NewReader(`{"cmd":"AT+CSQ"}`),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !server.handleAT(recorder, request, "dev1") {
|
||||||
|
t.Fatal("handleAT returned false")
|
||||||
|
}
|
||||||
|
if recorder.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want 502", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
|||||||
}, "\n"),
|
}, "\n"),
|
||||||
Time: run.FinishedAt, Task: task, Run: run,
|
Time: run.FinishedAt, Task: task, Run: run,
|
||||||
}
|
}
|
||||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||||
continue
|
continue
|
||||||
@@ -91,6 +91,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
|||||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||||
|
case "lark":
|
||||||
|
return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message))
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
||||||
return true
|
return true
|
||||||
|
case "dashboard/host":
|
||||||
|
s.handleDashboardHost(w, r)
|
||||||
|
return true
|
||||||
case "devices":
|
case "devices":
|
||||||
return s.handleDevices(w, r)
|
return s.handleDevices(w, r)
|
||||||
case "devices/discovered":
|
case "devices/discovered":
|
||||||
@@ -387,6 +390,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
|||||||
result = append(result, map[string]any{
|
result = append(result, map[string]any{
|
||||||
"hardware_kind": candidate.HardwareKind,
|
"hardware_kind": candidate.HardwareKind,
|
||||||
"reader_name": candidate.ReaderName,
|
"reader_name": candidate.ReaderName,
|
||||||
|
"device_type": discoveredDeviceType(candidate),
|
||||||
"discovery_key": entry.ID,
|
"discovery_key": entry.ID,
|
||||||
"control_path": controlPath,
|
"control_path": controlPath,
|
||||||
"net_interface": candidate.NetworkInterface,
|
"net_interface": candidate.NetworkInterface,
|
||||||
@@ -1055,6 +1059,26 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
response, err := s.devices.ExecuteAT(ctx, id, command)
|
response, err := s.devices.ExecuteAT(ctx, id, command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var commandErr *modem.CommandError
|
||||||
|
if errors.As(err, &commandErr) {
|
||||||
|
// The modem answered with ERROR / +CME ERROR. An AT terminal must
|
||||||
|
// surface that text (including the CME detail) as a normal response;
|
||||||
|
// folding it into a 502 hides the real reason from the user.
|
||||||
|
text := strings.Join(commandErr.Lines, "\n")
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += commandErr.Final
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"data": map[string]any{
|
||||||
|
"response": text,
|
||||||
|
"final": commandErr.Final,
|
||||||
|
"duration_ms": 0,
|
||||||
|
"urcs": []string{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
s.writeDeviceError(w, err)
|
s.writeDeviceError(w, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1612,7 +1636,14 @@ func (s *Server) configuredDeviceOverview(
|
|||||||
result["id"] = config.ID
|
result["id"] = config.ID
|
||||||
result["name"] = config.Name
|
result["name"] = config.Name
|
||||||
result["interface"] = config.Interface
|
result["interface"] = config.Interface
|
||||||
result["at_port"] = config.ATPort
|
// ttyUSB allocation changes across USB reconnects and boot cycles. The AT
|
||||||
|
// terminal must use only the currently discovered physical port; a stored
|
||||||
|
// path may point at another modem after enumeration order changes.
|
||||||
|
liveATPort := ""
|
||||||
|
if present {
|
||||||
|
liveATPort = entry.Candidate.ATPort.OpenPath()
|
||||||
|
}
|
||||||
|
result["at_port"] = liveATPort
|
||||||
result["audio_device"] = config.AudioDevice
|
result["audio_device"] = config.AudioDevice
|
||||||
result["backend_mode"] = config.DeviceBackend
|
result["backend_mode"] = config.DeviceBackend
|
||||||
result["control_device"] = config.ControlDevice
|
result["control_device"] = config.ControlDevice
|
||||||
@@ -1686,56 +1717,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
|||||||
enabled, _ := extra["enabled"].(bool)
|
enabled, _ := extra["enabled"].(bool)
|
||||||
active, _ := extra["active"].(bool)
|
active, _ := extra["active"].(bool)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"device_id": runtime.DeviceID,
|
"device_id": runtime.DeviceID,
|
||||||
"phase": runtime.Phase,
|
"phase": runtime.Phase,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"active": active,
|
"active": active,
|
||||||
"dataplane_mode": runtime.DataplaneMode,
|
"carrier_profile": extra["carrier_profile"],
|
||||||
"iccid": runtime.ICCID,
|
"carrier_profile_from": extra["carrier_profile_from"],
|
||||||
"imsi": runtime.IMSI,
|
"dataplane_mode": runtime.DataplaneMode,
|
||||||
"sim_ready": runtime.SIMReady,
|
"iccid": runtime.ICCID,
|
||||||
"access_ready": runtime.AccessReady,
|
"imsi": runtime.IMSI,
|
||||||
"tunnel_ready": runtime.TunnelReady,
|
"sim_ready": runtime.SIMReady,
|
||||||
"ims_ready": runtime.IMSReady,
|
"access_ready": runtime.AccessReady,
|
||||||
"sms_ready": runtime.SMSReady,
|
"tunnel_ready": runtime.TunnelReady,
|
||||||
"reg_status": runtime.RegStatus,
|
"ims_ready": runtime.IMSReady,
|
||||||
"reg_status_text": runtime.RegStatusText,
|
"sms_ready": runtime.SMSReady,
|
||||||
"network_mode": runtime.NetworkMode,
|
"reg_status": runtime.RegStatus,
|
||||||
"local_phone": runtime.LocalPhone,
|
"reg_status_text": runtime.RegStatusText,
|
||||||
"phone_number_source": runtime.PhoneNumberSource,
|
"network_mode": runtime.NetworkMode,
|
||||||
"last_error_class": runtime.LastErrorClass,
|
"local_phone": runtime.LocalPhone,
|
||||||
"last_error": runtime.LastError,
|
"phone_number_source": runtime.PhoneNumberSource,
|
||||||
"last_reason": runtime.LastReason,
|
"last_error_class": runtime.LastErrorClass,
|
||||||
"updated_at": runtime.UpdatedAt,
|
"last_error": runtime.LastError,
|
||||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
"last_reason": runtime.LastReason,
|
||||||
"imscore": rawJSONObject(runtime.IMSCore),
|
"updated_at": runtime.UpdatedAt,
|
||||||
"smsip": rawJSONObject(runtime.SMSIP),
|
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||||
|
"imscore": rawJSONObject(runtime.IMSCore),
|
||||||
|
"smsip": rawJSONObject(runtime.SMSIP),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"device_id": runtime.DeviceID,
|
"device_id": runtime.DeviceID,
|
||||||
"phase": string(runtime.Phase),
|
"phase": string(runtime.Phase),
|
||||||
"enabled": runtime.Enabled,
|
"enabled": runtime.Enabled,
|
||||||
"active": runtime.Active,
|
"active": runtime.Active,
|
||||||
"dataplane_mode": runtime.DataplaneMode,
|
"carrier_profile": runtime.CarrierProfile,
|
||||||
"iccid": runtime.ICCID,
|
"carrier_profile_from": runtime.CarrierProfileFrom,
|
||||||
"imsi": runtime.IMSI,
|
"dataplane_mode": runtime.DataplaneMode,
|
||||||
"sim_ready": runtime.SIMReady,
|
"iccid": runtime.ICCID,
|
||||||
"access_ready": runtime.AccessReady,
|
"imsi": runtime.IMSI,
|
||||||
"tunnel_ready": runtime.TunnelReady,
|
"sim_ready": runtime.SIMReady,
|
||||||
"ims_ready": runtime.IMSReady,
|
"access_ready": runtime.AccessReady,
|
||||||
"sms_ready": runtime.SMSReady,
|
"tunnel_ready": runtime.TunnelReady,
|
||||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
"ims_ready": runtime.IMSReady,
|
||||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
"sms_ready": runtime.SMSReady,
|
||||||
"network_mode": "Wi-Fi",
|
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||||
"local_phone": runtime.PhoneNumber,
|
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||||
"phone_number_source": runtime.PhoneNumberSource,
|
"network_mode": "Wi-Fi",
|
||||||
"last_error_class": runtime.LastErrorClass,
|
"local_phone": runtime.PhoneNumber,
|
||||||
"last_error": runtime.LastError,
|
"phone_number_source": runtime.PhoneNumberSource,
|
||||||
"last_reason": runtime.LastReason,
|
"last_error_class": runtime.LastErrorClass,
|
||||||
"updated_at": runtime.UpdatedAt,
|
"last_error": runtime.LastError,
|
||||||
|
"last_reason": runtime.LastReason,
|
||||||
|
"updated_at": runtime.UpdatedAt,
|
||||||
"tunnel": map[string]any{
|
"tunnel": map[string]any{
|
||||||
"established": runtime.TunnelReady,
|
"established": runtime.TunnelReady,
|
||||||
"name": runtime.TunnelName,
|
"name": runtime.TunnelName,
|
||||||
@@ -1909,6 +1944,8 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
|||||||
config.NetworkEnabled = false
|
config.NetworkEnabled = false
|
||||||
config.SMSEnabled = true
|
config.SMSEnabled = true
|
||||||
config.VoWiFiEnabled = true
|
config.VoWiFiEnabled = true
|
||||||
|
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||||
|
config.DeviceType = store.DeviceTypeDJI4G
|
||||||
}
|
}
|
||||||
if config.Interface == "" {
|
if config.Interface == "" {
|
||||||
config.Interface = candidate.NetworkInterface
|
config.Interface = candidate.NetworkInterface
|
||||||
@@ -1933,6 +1970,16 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func discoveredDeviceType(candidate modem.Candidate) string {
|
||||||
|
if candidate.HardwareKind == "pcsc" {
|
||||||
|
return store.DeviceTypeUSBSIMReader
|
||||||
|
}
|
||||||
|
if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||||
|
return store.DeviceTypeDJI4G
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
|||||||
@@ -6,10 +6,28 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/modem"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestFillConfigFromPhysicalClassifiesDJI4G(t *testing.T) {
|
||||||
|
config := store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}
|
||||||
|
entry := device.Device{Candidate: modem.Candidate{
|
||||||
|
VendorID: "2ca3",
|
||||||
|
ProductID: "4006",
|
||||||
|
}}
|
||||||
|
|
||||||
|
fillConfigFromPhysical(&config, entry)
|
||||||
|
|
||||||
|
if config.DeviceType != store.DeviceTypeDJI4G {
|
||||||
|
t.Fatalf("device type = %q, want %q", config.DeviceType, store.DeviceTypeDJI4G)
|
||||||
|
}
|
||||||
|
if got := discoveredDeviceType(entry.Candidate); got != store.DeviceTypeDJI4G {
|
||||||
|
t.Fatalf("discovered device type = %q, want %q", got, store.DeviceTypeDJI4G)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||||
database, err := store.Open(context.Background(), ":memory:")
|
database, err := store.Open(context.Background(), ":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -116,6 +134,29 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfiguredDeviceOverviewAlwaysUsesLiveDiscoveredATPort(t *testing.T) {
|
||||||
|
database, err := store.Open(context.Background(), ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
s := &Server{store: database}
|
||||||
|
config := store.Device{ID: "ec20_1", ATPort: "/dev/ttyUSB9"}
|
||||||
|
entry := device.Device{Candidate: modem.Candidate{
|
||||||
|
ATPort: modem.Port{Path: "/dev/ttyUSB2", Role: modem.PortRoleAT},
|
||||||
|
}}
|
||||||
|
|
||||||
|
connected := s.configuredDeviceOverview(config, entry, true)
|
||||||
|
if got := connected["at_port"]; got != "/dev/ttyUSB2" {
|
||||||
|
t.Fatalf("connected AT port = %#v, want live /dev/ttyUSB2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
offline := s.configuredDeviceOverview(config, entry, false)
|
||||||
|
if got := offline["at_port"]; got != "" {
|
||||||
|
t.Fatalf("offline AT port = %#v, want empty instead of stored port", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
||||||
for _, snapshot := range []*device.Snapshot{
|
for _, snapshot := range []*device.Snapshot{
|
||||||
{IMEI: "867123456789012"},
|
{IMEI: "867123456789012"},
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.store.Ready(ctx); err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMetrics exposes only process-level, non-identifying Prometheus data.
|
||||||
|
// Device IDs, SIM identities, phone numbers and proxy information never enter
|
||||||
|
// this unauthenticated endpoint.
|
||||||
|
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ready := 0
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||||
|
if s.store.Ready(ctx) == nil {
|
||||||
|
ready = 1
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
fmt.Fprint(w, "# HELP vocat_up Whether the process is running.\n# TYPE vocat_up gauge\nvocat_up 1\n")
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_ready Whether the database is ready.\n# TYPE vocat_ready gauge\nvocat_ready %d\n", ready)
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_uptime_seconds Process uptime.\n# TYPE vocat_uptime_seconds counter\nvocat_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_go_goroutines Current Go goroutines.\n# TYPE vocat_go_goroutines gauge\nvocat_go_goroutines %d\n", runtime.NumGoroutine())
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOperationalHealthEndpointsAreAnonymousAndNonIdentifying(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
contentType string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{path: "/healthz", contentType: "application/json", contains: `"status":"ok"`},
|
||||||
|
{path: "/readyz", contentType: "application/json", contains: `"status":"ready"`},
|
||||||
|
{path: "/metrics", contentType: "text/plain", contains: "vocat_ready 1"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.path, func(t *testing.T) {
|
||||||
|
response, err := app.client.Get(app.server.URL + test.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Header.Get("Content-Type"), test.contentType) {
|
||||||
|
t.Fatalf("Content-Type = %q", response.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), test.contains) {
|
||||||
|
t.Fatalf("body = %q, want %q", body, test.contains)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"imsi", "iccid", "msisdn", "proxy", "device_id"} {
|
||||||
|
if strings.Contains(strings.ToLower(string(body)), forbidden) {
|
||||||
|
t.Fatalf("body exposes forbidden label %q: %s", forbidden, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationalHealthEndpointsRejectPOST(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
for _, path := range []string{"/healthz", "/readyz", "/metrics"} {
|
||||||
|
request, err := http.NewRequest(http.MethodPost, app.server.URL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response, err := app.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("%s status = %d, want %d", path, response.StatusCode, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostStaticInfo describes hardware identities that do not change while the
|
||||||
|
// process runs, so they are probed once and cached.
|
||||||
|
type hostStaticInfo struct {
|
||||||
|
CPUModel string `json:"cpu_model"`
|
||||||
|
BoardModel string `json:"board_model"`
|
||||||
|
MemoryModel string `json:"memory_model"`
|
||||||
|
DiskModel string `json:"disk_model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostPerfSnapshot is one rendered read of host utilization for the dashboard.
|
||||||
|
type hostPerfSnapshot struct {
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemoryPercent float64 `json:"memory_percent"`
|
||||||
|
MemoryUsed uint64 `json:"memory_used_bytes"`
|
||||||
|
MemoryTotal uint64 `json:"memory_total_bytes"`
|
||||||
|
DiskPercent float64 `json:"disk_percent"`
|
||||||
|
DiskUsed uint64 `json:"disk_used_bytes"`
|
||||||
|
DiskTotal uint64 `json:"disk_total_bytes"`
|
||||||
|
NetRxBps float64 `json:"net_rx_bps"`
|
||||||
|
NetTxBps float64 `json:"net_tx_bps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostCPUTimes is one cumulative /proc/stat reading: idle already includes
|
||||||
|
// iowait, total sums every other column (guest time is already folded into
|
||||||
|
// user/nice and therefore excluded).
|
||||||
|
type hostCPUTimes struct {
|
||||||
|
idle uint64
|
||||||
|
total uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// hostStatsMinGap keeps back-to-back polls from dividing a handful of
|
||||||
|
// jiffies by a few milliseconds; the previous rate is reused instead.
|
||||||
|
hostStatsMinGap = 300 * time.Millisecond
|
||||||
|
// hostStatsMaxGap mirrors liveNetMaxGap: a gap past this means the tab was
|
||||||
|
// closed or the browser was hidden; re-baseline instead of averaging a
|
||||||
|
// long dead interval.
|
||||||
|
hostStatsMaxGap = 15 * time.Second
|
||||||
|
// hostStatsFirstSample is how long the very first request blocks so CPU
|
||||||
|
// and network readings have a real interval to average over. It must
|
||||||
|
// exceed hostStatsMinGap so the re-read survives the min-gap guard below.
|
||||||
|
hostStatsFirstSample = 400 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostStatsSampler derives live host utilization from cumulative kernel
|
||||||
|
// counters. Like liveNetTracker it is driven on demand by dashboard polling,
|
||||||
|
// so no background goroutine is required.
|
||||||
|
type hostStatsSampler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
static *hostStaticInfo
|
||||||
|
sampledAt time.Time
|
||||||
|
prevCPU hostCPUTimes
|
||||||
|
prevNetRx uint64
|
||||||
|
prevNetTx uint64
|
||||||
|
lastCPU float64
|
||||||
|
lastRxBps float64
|
||||||
|
lastTxBps float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHostStatsSampler() *hostStatsSampler {
|
||||||
|
return &hostStatsSampler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDashboardHost serves the dashboard host card: static hardware identity
|
||||||
|
// plus live utilization. Both halves are cheap reads of /proc and /sys.
|
||||||
|
func (s *Server) handleDashboardHost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||||
|
"host": s.hostStats.info(),
|
||||||
|
"perf": s.hostStats.perf(),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// info returns the cached static hardware description, probing it on first use.
|
||||||
|
func (s *hostStatsSampler) info() hostStaticInfo {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.static == nil {
|
||||||
|
info := probeHostStatic()
|
||||||
|
s.static = &info
|
||||||
|
}
|
||||||
|
return *s.static
|
||||||
|
}
|
||||||
|
|
||||||
|
// perf renders one utilization snapshot. CPU and network rates need a baseline,
|
||||||
|
// so the first-ever call takes a short inline second reading; later calls
|
||||||
|
// average against the previous request.
|
||||||
|
func (s *hostStatsSampler) perf() hostPerfSnapshot {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cpu, cpuOK := readHostCPUTimes()
|
||||||
|
rx, tx, netOK := readHostNetTotals()
|
||||||
|
|
||||||
|
// No usable baseline yet (first request, or the tab was hidden past the
|
||||||
|
// max gap): establish one, then re-read after a short interval so the
|
||||||
|
// first dashboard paint already reports real numbers.
|
||||||
|
needBaseline := s.sampledAt.IsZero() || now.Sub(s.sampledAt) > hostStatsMaxGap
|
||||||
|
if needBaseline && (cpuOK || netOK) {
|
||||||
|
s.sampledAt = now
|
||||||
|
if cpuOK {
|
||||||
|
s.prevCPU = cpu
|
||||||
|
}
|
||||||
|
if netOK {
|
||||||
|
s.prevNetRx, s.prevNetTx = rx, tx
|
||||||
|
}
|
||||||
|
time.Sleep(hostStatsFirstSample)
|
||||||
|
now = time.Now()
|
||||||
|
if next, ok := readHostCPUTimes(); ok {
|
||||||
|
cpu, cpuOK = next, true
|
||||||
|
}
|
||||||
|
if nextRx, nextTx, ok := readHostNetTotals(); ok {
|
||||||
|
rx, tx, netOK = nextRx, nextTx, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memPercent, memUsed, memTotal := readHostMemory()
|
||||||
|
diskPercent, diskUsed, diskTotal := readHostDisk()
|
||||||
|
|
||||||
|
gap := now.Sub(s.sampledAt)
|
||||||
|
if gap >= hostStatsMinGap && (cpuOK || netOK) {
|
||||||
|
if cpuOK {
|
||||||
|
if busyDelta, totalDelta := cpuDelta(s.prevCPU, cpu); totalDelta > 0 {
|
||||||
|
s.lastCPU = clampPercent(float64(busyDelta) * 100 / float64(totalDelta))
|
||||||
|
}
|
||||||
|
s.prevCPU = cpu
|
||||||
|
}
|
||||||
|
if netOK {
|
||||||
|
// Counter resets (interface flap) must not produce a giant spike.
|
||||||
|
if rx >= s.prevNetRx {
|
||||||
|
s.lastRxBps = float64(rx-s.prevNetRx) / gap.Seconds()
|
||||||
|
} else {
|
||||||
|
s.lastRxBps = 0
|
||||||
|
}
|
||||||
|
if tx >= s.prevNetTx {
|
||||||
|
s.lastTxBps = float64(tx-s.prevNetTx) / gap.Seconds()
|
||||||
|
} else {
|
||||||
|
s.lastTxBps = 0
|
||||||
|
}
|
||||||
|
s.prevNetRx, s.prevNetTx = rx, tx
|
||||||
|
}
|
||||||
|
s.sampledAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostPerfSnapshot{
|
||||||
|
CPUPercent: s.lastCPU,
|
||||||
|
MemoryPercent: memPercent,
|
||||||
|
MemoryUsed: memUsed,
|
||||||
|
MemoryTotal: memTotal,
|
||||||
|
DiskPercent: diskPercent,
|
||||||
|
DiskUsed: diskUsed,
|
||||||
|
DiskTotal: diskTotal,
|
||||||
|
NetRxBps: s.lastRxBps,
|
||||||
|
NetTxBps: s.lastTxBps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cpuDelta returns the busy and total jiffies elapsed between two cumulative
|
||||||
|
// readings. A backwards counter (theoretically impossible for /proc/stat)
|
||||||
|
// reports zero rather than wrapping.
|
||||||
|
func cpuDelta(prev, next hostCPUTimes) (busy, total uint64) {
|
||||||
|
if next.total <= prev.total || next.idle < prev.idle {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
totalDelta := next.total - prev.total
|
||||||
|
idleDelta := next.idle - prev.idle
|
||||||
|
if idleDelta >= totalDelta {
|
||||||
|
return 0, totalDelta
|
||||||
|
}
|
||||||
|
return totalDelta - idleDelta, totalDelta
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampPercent(value float64) float64 {
|
||||||
|
switch {
|
||||||
|
case value < 0:
|
||||||
|
return 0
|
||||||
|
case value > 100:
|
||||||
|
return 100
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostNetIgnoredPrefixes are virtual interface name prefixes whose counters
|
||||||
|
// would double-count physical traffic (bridges, tunnels, vocat's own links) or
|
||||||
|
// carry no real host traffic at all.
|
||||||
|
var hostNetIgnoredPrefixes = []string{
|
||||||
|
"lo", "br-", "docker", "veth", "virbr", "vmnet", "vboxnet",
|
||||||
|
"ip6tnl", "ip6gre", "sit", "gre", "gretap", "erspan",
|
||||||
|
"tun", "tap", "utun", "vocat", "wg", "zt", "tailscale",
|
||||||
|
"ifb", "bond", "vlan", "macvlan", "dummy", "lxc", "cali", "flannel", "cni",
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostNetInterfaceCounted reports whether an interface's byte counters feed the
|
||||||
|
// host-level upload/download rates.
|
||||||
|
func hostNetInterfaceCounted(name string) bool {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, prefix := range hostNetIgnoredPrefixes {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNetDevCounters sums rx/tx bytes across counted interfaces in
|
||||||
|
// /proc/net/dev content.
|
||||||
|
func parseNetDevCounters(content string) (rx, tx uint64) {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
name, rest, found := strings.Cut(line, ":")
|
||||||
|
if !found || !hostNetInterfaceCounted(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(rest)
|
||||||
|
if len(fields) < 9 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rxBytes, okRx := parseUint(fields[0])
|
||||||
|
txBytes, okTx := parseUint(fields[8])
|
||||||
|
if !okRx || !okTx {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rx += rxBytes
|
||||||
|
tx += txBytes
|
||||||
|
}
|
||||||
|
return rx, tx
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint(text string) (uint64, bool) {
|
||||||
|
value, err := strconv.ParseUint(strings.TrimSpace(text), 10, 64)
|
||||||
|
return value, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCPUTimes parses the aggregate "cpu" line of /proc/stat.
|
||||||
|
func parseCPUTimes(line string) (hostCPUTimes, bool) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
// cpu user nice system idle iowait irq softirq steal [guest guest_nice]
|
||||||
|
if len(fields) < 9 || fields[0] != "cpu" {
|
||||||
|
return hostCPUTimes{}, false
|
||||||
|
}
|
||||||
|
var times hostCPUTimes
|
||||||
|
for index, field := range fields[1:9] {
|
||||||
|
value, ok := parseUint(field)
|
||||||
|
if !ok {
|
||||||
|
return hostCPUTimes{}, false
|
||||||
|
}
|
||||||
|
times.total += value
|
||||||
|
if index == 3 || index == 4 { // idle + iowait
|
||||||
|
times.idle += value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return times, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMeminfo extracts MemTotal and MemAvailable (bytes) from /proc/meminfo.
|
||||||
|
func parseMeminfo(content string) (total, available uint64, ok bool) {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
key, rest, found := strings.Cut(line, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var value uint64
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "MemTotal":
|
||||||
|
value, ok = parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB")))
|
||||||
|
if ok {
|
||||||
|
total = value * 1024
|
||||||
|
}
|
||||||
|
case "MemAvailable":
|
||||||
|
if value, parsed := parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB"))); parsed {
|
||||||
|
available = value * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, available, total > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCPUInfoModel returns the x86-style "model name" from /proc/cpuinfo, or
|
||||||
|
// an empty string on ARM hosts that only carry CPU part numbers.
|
||||||
|
func parseCPUInfoModel(content string) string {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
key, value, found := strings.Cut(line, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "model name", "Model", "Hardware":
|
||||||
|
if model := strings.TrimSpace(value); model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCPUInfoPart returns the first ARM "CPU part" hex identifier (e.g.
|
||||||
|
// 0xd03) and the number of processors listed.
|
||||||
|
func parseCPUInfoPart(content string) (part string, processors int) {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
key, value, found := strings.Cut(line, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "processor":
|
||||||
|
processors++
|
||||||
|
case "CPU part":
|
||||||
|
if part == "" {
|
||||||
|
part = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part, processors
|
||||||
|
}
|
||||||
|
|
||||||
|
// armCPUPartNames maps ARM CPU part identifiers to marketing core names.
|
||||||
|
var armCPUPartNames = map[string]string{
|
||||||
|
"0xd03": "Cortex-A53",
|
||||||
|
"0xd04": "Cortex-A35",
|
||||||
|
"0xd05": "Cortex-A55",
|
||||||
|
"0xd06": "Cortex-A65",
|
||||||
|
"0xd07": "Cortex-A57",
|
||||||
|
"0xd08": "Cortex-A72",
|
||||||
|
"0xd09": "Cortex-A73",
|
||||||
|
"0xd0a": "Cortex-A75",
|
||||||
|
"0xd0b": "Cortex-A76",
|
||||||
|
"0xd0c": "Neoverse-N1",
|
||||||
|
"0xd0d": "Cortex-A77",
|
||||||
|
"0xd0e": "Cortex-A76AE",
|
||||||
|
"0xd40": "Neoverse-V1",
|
||||||
|
"0xd41": "Cortex-A78",
|
||||||
|
"0xd42": "Cortex-A78AE",
|
||||||
|
"0xd44": "Cortex-X1",
|
||||||
|
"0xd46": "Cortex-A510",
|
||||||
|
"0xd47": "Cortex-A710",
|
||||||
|
"0xd48": "Cortex-X2",
|
||||||
|
"0xd4b": "Cortex-A715",
|
||||||
|
"0xd4d": "Cortex-A520",
|
||||||
|
"0xd4e": "Cortex-X3",
|
||||||
|
}
|
||||||
|
|
||||||
|
// socVendorNames prettifies the vendor half of a device-tree compatible entry.
|
||||||
|
var socVendorNames = map[string]string{
|
||||||
|
"allwinner": "Allwinner",
|
||||||
|
"amlogic": "Amlogic",
|
||||||
|
"broadcom": "Broadcom",
|
||||||
|
"mediatek": "MediaTek",
|
||||||
|
"nvidia": "NVIDIA",
|
||||||
|
"qualcomm": "Qualcomm",
|
||||||
|
"raspberrypi": "Raspberry Pi",
|
||||||
|
"rockchip": "Rockchip",
|
||||||
|
"samsung": "Samsung",
|
||||||
|
"ti": "TI",
|
||||||
|
"xunlong": "Xunlong",
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCompatibleSoC extracts the SoC half of a device-tree compatible list
|
||||||
|
// (NUL-separated, most specific first): "xunlong,orangepi-zero3\0allwinner,
|
||||||
|
// sun50i-h618\0" yields "Allwinner sun50i-h618".
|
||||||
|
func parseCompatibleSoC(raw string) string {
|
||||||
|
entries := strings.FieldsFunc(raw, func(r rune) bool { return r == 0 || r == '\n' })
|
||||||
|
// The last entry is the least specific compatible, which on ARM boards is
|
||||||
|
// the SoC rather than the board.
|
||||||
|
for index := len(entries) - 1; index >= 0; index-- {
|
||||||
|
entry := strings.TrimSpace(entries[index])
|
||||||
|
vendor, soc, found := strings.Cut(entry, ",")
|
||||||
|
if !found || soc == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pretty, ok := socVendorNames[strings.ToLower(vendor)]; ok {
|
||||||
|
vendor = pretty
|
||||||
|
} else {
|
||||||
|
vendor = strings.ToUpper(vendor[:1]) + vendor[1:]
|
||||||
|
}
|
||||||
|
return vendor + " " + soc
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeARMCPUModel renders e.g. "Allwinner sun50i-h618 · 4× Cortex-A53".
|
||||||
|
func composeARMCPUModel(soc, part string, processors int) string {
|
||||||
|
core := armCPUPartNames[part]
|
||||||
|
var result string
|
||||||
|
switch {
|
||||||
|
case soc != "" && core != "" && processors > 0:
|
||||||
|
result = soc + " · " + strconv.Itoa(processors) + "× " + core
|
||||||
|
case soc != "" && processors > 0:
|
||||||
|
result = soc + " · " + strconv.Itoa(processors) + "× CPU"
|
||||||
|
case soc != "" && core != "":
|
||||||
|
result = soc + " · " + core
|
||||||
|
default:
|
||||||
|
result = soc
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipHostDisk reports whether a /sys/block entry is a virtual device whose
|
||||||
|
// "model" would only clutter the host card.
|
||||||
|
func skipHostDisk(name string) bool {
|
||||||
|
for _, prefix := range []string{"loop", "ram", "zram", "sr", "nbd", "dm-", "md", "mtdblock", "ubi", "ubiblock"} {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDmidecodeMemory extracts a compact "8 GB DDR4 M471A1K43CB1-CRC" style
|
||||||
|
// description from `dmidecode -t 17` output, preferring the first populated
|
||||||
|
// slot. Empty when no installed module can be described.
|
||||||
|
func parseDmidecodeMemory(output string) string {
|
||||||
|
var size, memType, partNumber string
|
||||||
|
flush := func() string {
|
||||||
|
if size != "" && partNumber != "" {
|
||||||
|
return strings.TrimSpace(size + " " + memType + " " + partNumber)
|
||||||
|
}
|
||||||
|
if size != "" && memType != "" {
|
||||||
|
return strings.TrimSpace(size + " " + memType)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "Memory Device") {
|
||||||
|
if composed := flush(); composed != "" {
|
||||||
|
return composed
|
||||||
|
}
|
||||||
|
size, memType, partNumber = "", "", ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, found := strings.Cut(trimmed, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "Size":
|
||||||
|
if !strings.Contains(value, "No Module") && value != "" && value != "Unknown" {
|
||||||
|
size = value
|
||||||
|
}
|
||||||
|
case "Type":
|
||||||
|
if value != "Unknown" && value != "Other" && !strings.HasPrefix(value, "<OUT OF SPEC") {
|
||||||
|
memType = value
|
||||||
|
}
|
||||||
|
case "Part Number":
|
||||||
|
if value != "Unknown" && value != "None" && value != "" {
|
||||||
|
partNumber = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeHostStatic gathers the hardware identities shown on the dashboard host
|
||||||
|
// card. Every probe is best-effort: empty fields render as "—" in the SPA.
|
||||||
|
func probeHostStatic() hostStaticInfo {
|
||||||
|
return hostStaticInfo{
|
||||||
|
CPUModel: readHostCPUModel(),
|
||||||
|
BoardModel: readHostBoardModel(),
|
||||||
|
MemoryModel: readHostMemoryModel(),
|
||||||
|
DiskModel: readHostDiskModel(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostCPUModel prefers the x86-style "model name"; on ARM hosts it composes
|
||||||
|
// the device-tree SoC with the core count and Cortex part name.
|
||||||
|
func readHostCPUModel() string {
|
||||||
|
cpuinfo, err := os.ReadFile("/proc/cpuinfo")
|
||||||
|
if err == nil {
|
||||||
|
if model := parseCPUInfoModel(string(cpuinfo)); model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
part, processors := parseCPUInfoPart(string(cpuinfo))
|
||||||
|
if processors == 0 {
|
||||||
|
processors = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
soc := ""
|
||||||
|
if compatible, err := os.ReadFile("/proc/device-tree/compatible"); err == nil {
|
||||||
|
soc = parseCompatibleSoC(string(compatible))
|
||||||
|
}
|
||||||
|
if model := composeARMCPUModel(soc, part, processors); model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runtime.GOARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostBoardModel reads the device-tree model on ARM boards and the DMI
|
||||||
|
// board name on x86 machines.
|
||||||
|
func readHostBoardModel() string {
|
||||||
|
if model, err := os.ReadFile("/proc/device-tree/model"); err == nil {
|
||||||
|
if text := strings.TrimSpace(strings.TrimRight(string(model), "\x00")); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dmiDir := "/sys/devices/virtual/dmi/id"
|
||||||
|
board := readSysfsTrimmed(filepath.Join(dmiDir, "board_name"))
|
||||||
|
vendor := readSysfsTrimmed(filepath.Join(dmiDir, "board_vendor"))
|
||||||
|
if board != "" && !isPlaceholderDMI(board) {
|
||||||
|
if vendor != "" && !isPlaceholderDMI(vendor) && !strings.Contains(strings.ToLower(board), strings.ToLower(vendor)) {
|
||||||
|
return vendor + " " + board
|
||||||
|
}
|
||||||
|
return board
|
||||||
|
}
|
||||||
|
if product := readSysfsTrimmed(filepath.Join(dmiDir, "product_name")); product != "" && !isPlaceholderDMI(product) {
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaceholderDMI filters the well-known "we never filled this in" DMI
|
||||||
|
// strings so they do not surface as board models.
|
||||||
|
func isPlaceholderDMI(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "", "default string", "to be filled by o.e.m.", "to be filled by o.e.m", "none", "unknown", "n/a", "not specified", "system manufacturer":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostMemoryModel reports the installed DIMM description when dmidecode is
|
||||||
|
// available (typical on x86 NAS/PC hosts) and falls back to total capacity,
|
||||||
|
// which is all an ARM board exposes.
|
||||||
|
func readHostMemoryModel() string {
|
||||||
|
if path, err := exec.LookPath("dmidecode"); err == nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if output, err := exec.CommandContext(ctx, path, "-t", "17").Output(); err == nil {
|
||||||
|
if model := parseDmidecodeMemory(string(output)); model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total, _, ok := readHostMemoryBytes(); ok {
|
||||||
|
return formatLiveBytes(float64(total))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostDiskModel describes physical block devices, skipping virtual ones
|
||||||
|
// (loop, zram, device-mapper, mtd, optical). Multiple disks join with "; ".
|
||||||
|
func readHostDiskModel() string {
|
||||||
|
entries, err := os.ReadDir("/sys/block")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var disks []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if skipHostDisk(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := filepath.Join("/sys/block", name)
|
||||||
|
sizeText := readSysfsTrimmed(filepath.Join(base, "size"))
|
||||||
|
sectors, ok := parseUint(sizeText)
|
||||||
|
if !ok || sectors == 0 {
|
||||||
|
// An empty card reader reports size 0 and tells us nothing.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
model := readSysfsTrimmed(filepath.Join(base, "device", "model"))
|
||||||
|
if model == "" {
|
||||||
|
// MMC/SD cards carry the product name instead of a model string.
|
||||||
|
model = readSysfsTrimmed(filepath.Join(base, "device", "name"))
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = name
|
||||||
|
}
|
||||||
|
capacity := formatLiveBytes(float64(sectors) * 512)
|
||||||
|
disks = append(disks, model+" · "+capacity)
|
||||||
|
}
|
||||||
|
return strings.Join(disks, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSysfsTrimmed(path string) string {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.TrimRight(string(raw), "\x00"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostCPUTimes reads the aggregate counters from /proc/stat.
|
||||||
|
func readHostCPUTimes() (hostCPUTimes, bool) {
|
||||||
|
raw, err := os.ReadFile("/proc/stat")
|
||||||
|
if err != nil {
|
||||||
|
return hostCPUTimes{}, false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
if strings.HasPrefix(line, "cpu ") {
|
||||||
|
return parseCPUTimes(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hostCPUTimes{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostMemoryBytes returns MemTotal and MemAvailable in bytes.
|
||||||
|
func readHostMemoryBytes() (total, available uint64, ok bool) {
|
||||||
|
raw, err := os.ReadFile("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return parseMeminfo(string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostMemory reports used/total bytes and the used percentage.
|
||||||
|
func readHostMemory() (percent float64, used, total uint64) {
|
||||||
|
total, available, ok := readHostMemoryBytes()
|
||||||
|
if !ok || total == 0 {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
used = total - available
|
||||||
|
return clampPercent(float64(used) * 100 / float64(total)), used, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostDisk reports root filesystem usage the way df does: usable space is
|
||||||
|
// total minus reserved blocks, and the percentage is used/(used+available).
|
||||||
|
func readHostDisk() (percent float64, used, total uint64) {
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if err := unix.Statfs("/", &stat); err != nil || stat.Blocks == 0 {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
blockSize := uint64(stat.Bsize)
|
||||||
|
total = stat.Blocks * blockSize
|
||||||
|
free := stat.Bfree * blockSize
|
||||||
|
available := stat.Bavail * blockSize
|
||||||
|
used = total - free
|
||||||
|
if denominator := used + available; denominator > 0 {
|
||||||
|
percent = clampPercent(float64(used) * 100 / float64(denominator))
|
||||||
|
}
|
||||||
|
return percent, used, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHostNetTotals sums rx/tx counters across physical host interfaces.
|
||||||
|
func readHostNetTotals() (rx, tx uint64, ok bool) {
|
||||||
|
raw, err := os.ReadFile("/proc/net/dev")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
rx, tx = parseNetDevCounters(string(raw))
|
||||||
|
return rx, tx, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
// Host statistics are only meaningful on the Linux deployment target; on other
|
||||||
|
// platforms every probe reports empty/zero and the dashboard renders "—".
|
||||||
|
|
||||||
|
func probeHostStatic() hostStaticInfo { return hostStaticInfo{} }
|
||||||
|
|
||||||
|
func readHostCPUTimes() (hostCPUTimes, bool) { return hostCPUTimes{}, false }
|
||||||
|
|
||||||
|
func readHostNetTotals() (uint64, uint64, bool) { return 0, 0, false }
|
||||||
|
|
||||||
|
func readHostMemory() (float64, uint64, uint64) { return 0, 0, 0 }
|
||||||
|
|
||||||
|
func readHostDisk() (float64, uint64, uint64) { return 0, 0, 0 }
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCPUTimes(t *testing.T) {
|
||||||
|
times, ok := parseCPUTimes("cpu 38073 0 24013 6762971 3121 0 3019 0 0 0")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseCPUTimes rejected a valid cpu line")
|
||||||
|
}
|
||||||
|
wantTotal := uint64(38073 + 0 + 24013 + 6762971 + 3121 + 0 + 3019 + 0)
|
||||||
|
if times.total != wantTotal {
|
||||||
|
t.Fatalf("total = %d, want %d", times.total, wantTotal)
|
||||||
|
}
|
||||||
|
if wantIdle := uint64(6762971 + 3121); times.idle != wantIdle {
|
||||||
|
t.Fatalf("idle = %d, want %d", times.idle, wantIdle)
|
||||||
|
}
|
||||||
|
if _, ok := parseCPUTimes("cpu0 1 2 3 4 5 6 7 8"); ok {
|
||||||
|
t.Fatal("per-core line must not parse as the aggregate line")
|
||||||
|
}
|
||||||
|
if _, ok := parseCPUTimes("cpu 1 2 3"); ok {
|
||||||
|
t.Fatal("truncated cpu line must not parse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCPUDelta(t *testing.T) {
|
||||||
|
prev := hostCPUTimes{idle: 100, total: 200}
|
||||||
|
next := hostCPUTimes{idle: 150, total: 300}
|
||||||
|
busy, total := cpuDelta(prev, next)
|
||||||
|
if busy != 50 || total != 100 {
|
||||||
|
t.Fatalf("cpuDelta = (%d, %d), want (50, 100)", busy, total)
|
||||||
|
}
|
||||||
|
if busy, total := cpuDelta(next, prev); busy != 0 || total != 0 {
|
||||||
|
t.Fatalf("backwards counters must report zero, got (%d, %d)", busy, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMeminfo(t *testing.T) {
|
||||||
|
content := "MemTotal: 2040424 kB\nMemFree: 920864 kB\nMemAvailable: 1543480 kB\nBuffers: 315908 kB\n"
|
||||||
|
total, available, ok := parseMeminfo(content)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseMeminfo rejected valid content")
|
||||||
|
}
|
||||||
|
if total != 2040424*1024 {
|
||||||
|
t.Fatalf("total = %d, want %d", total, 2040424*1024)
|
||||||
|
}
|
||||||
|
if available != 1543480*1024 {
|
||||||
|
t.Fatalf("available = %d, want %d", available, 1543480*1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNetDevCounters(t *testing.T) {
|
||||||
|
content := `Inter-| Receive | Transmit
|
||||||
|
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||||
|
lo: 10 1 0 0 0 0 0 0 20 2 0 0 0 0 0 0
|
||||||
|
eth0: 100 1 0 0 0 0 0 0 200 2 0 0 0 0 0 0
|
||||||
|
br-lan: 1000 1 0 0 0 0 0 0 2000 2 0 0 0 0 0 0
|
||||||
|
utun: 300 1 0 0 0 0 0 0 400 2 0 0 0 0 0 0
|
||||||
|
vocat50a684ceb0: 500 1 0 0 0 0 0 0 600 2 0 0 0 0 0 0
|
||||||
|
wwan0: 700 1 0 0 0 0 0 0 800 2 0 0 0 0 0 0
|
||||||
|
`
|
||||||
|
rx, tx := parseNetDevCounters(content)
|
||||||
|
// Only eth0 and wwan0 count; lo, br-lan, utun and vocat are virtual.
|
||||||
|
if rx != 800 || tx != 1000 {
|
||||||
|
t.Fatalf("rx,tx = %d,%d, want 800,1000", rx, tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostNetInterfaceCounted(t *testing.T) {
|
||||||
|
counted := []string{"eth0", "eth1", "wwan0", "usb0", "wlan0", "enp3s0", "pppoe-wan"}
|
||||||
|
for _, name := range counted {
|
||||||
|
if !hostNetInterfaceCounted(name) {
|
||||||
|
t.Fatalf("%s should be counted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipped := []string{"lo", "br-lan", "docker0", "veth123", "ip6tnl0", "sit0", "utun", "vocat50a684ceb0", "wg0", "tun0", "tailscale0", ""}
|
||||||
|
for _, name := range skipped {
|
||||||
|
if hostNetInterfaceCounted(name) {
|
||||||
|
t.Fatalf("%s should be skipped", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCPUInfoModelX86(t *testing.T) {
|
||||||
|
content := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz\n"
|
||||||
|
if model := parseCPUInfoModel(content); model != "Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz" {
|
||||||
|
t.Fatalf("model = %q", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCPUInfoARM(t *testing.T) {
|
||||||
|
content := "processor\t: 0\nBogoMIPS\t: 48.00\nCPU implementer\t: 0x41\nCPU part\t: 0xd03\nprocessor\t: 1\nCPU part\t: 0xd03\n"
|
||||||
|
if model := parseCPUInfoModel(content); model != "" {
|
||||||
|
t.Fatalf("ARM cpuinfo must not report an x86 model name, got %q", model)
|
||||||
|
}
|
||||||
|
part, processors := parseCPUInfoPart(content)
|
||||||
|
if part != "0xd03" || processors != 2 {
|
||||||
|
t.Fatalf("part,processors = %q,%d, want 0xd03,2", part, processors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCompatibleSoC(t *testing.T) {
|
||||||
|
raw := "xunlong,orangepi-zero3\x00allwinner,sun50i-h618\x00"
|
||||||
|
if soc := parseCompatibleSoC(raw); soc != "Allwinner sun50i-h618" {
|
||||||
|
t.Fatalf("soc = %q", soc)
|
||||||
|
}
|
||||||
|
if soc := parseCompatibleSoC(""); soc != "" {
|
||||||
|
t.Fatalf("empty compatible must yield empty soc, got %q", soc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeARMCPUModel(t *testing.T) {
|
||||||
|
model := composeARMCPUModel("Allwinner sun50i-h618", "0xd03", 4)
|
||||||
|
if model != "Allwinner sun50i-h618 · 4× Cortex-A53" {
|
||||||
|
t.Fatalf("model = %q", model)
|
||||||
|
}
|
||||||
|
if model := composeARMCPUModel("", "", 0); model != "" {
|
||||||
|
t.Fatalf("empty inputs must yield empty model, got %q", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDmidecodeMemory(t *testing.T) {
|
||||||
|
output := `# dmidecode 3.3
|
||||||
|
Getting SMBIOS data from sysfs.
|
||||||
|
SMBIOS 3.0 present.
|
||||||
|
|
||||||
|
Handle 0x0010, DMI type 17, 40 bytes
|
||||||
|
Memory Device
|
||||||
|
Array Handle: 0x000F
|
||||||
|
Error Information Handle: Not Provided
|
||||||
|
Total Width: 64 bits
|
||||||
|
Data Width: 64 bits
|
||||||
|
Size: 8 GB
|
||||||
|
Form Factor: SODIMM
|
||||||
|
Type: DDR4
|
||||||
|
Speed: 2400 MT/s
|
||||||
|
Manufacturer: Samsung
|
||||||
|
Serial Number: 12345678
|
||||||
|
Part Number: M471A1K43CB1-CRC
|
||||||
|
Rank: 1
|
||||||
|
|
||||||
|
Handle 0x0011, DMI type 17, 40 bytes
|
||||||
|
Memory Device
|
||||||
|
Size: No Module Installed
|
||||||
|
Type: Unknown
|
||||||
|
`
|
||||||
|
if model := parseDmidecodeMemory(output); model != "8 GB DDR4 M471A1K43CB1-CRC" {
|
||||||
|
t.Fatalf("model = %q", model)
|
||||||
|
}
|
||||||
|
if model := parseDmidecodeMemory("Memory Device\n\tSize: No Module Installed\n"); model != "" {
|
||||||
|
t.Fatalf("unpopulated slots must yield empty model, got %q", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClampPercent(t *testing.T) {
|
||||||
|
if clampPercent(-1) != 0 || clampPercent(101) != 100 || clampPercent(50) != 50 {
|
||||||
|
t.Fatal("clampPercent bounds violated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUintTrims(t *testing.T) {
|
||||||
|
if value, ok := parseUint(" 61440000 "); !ok || value != 61440000 {
|
||||||
|
t.Fatalf("parseUint = %d,%v", value, ok)
|
||||||
|
}
|
||||||
|
if _, ok := parseUint("not-a-number"); ok {
|
||||||
|
t.Fatal("parseUint accepted garbage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipHostDiskPrefixes(t *testing.T) {
|
||||||
|
skipped := []string{"loop0", "ram0", "zram0", "sr0", "nbd0", "dm-0", "md0", "mtdblock0", "ubiblock0_0"}
|
||||||
|
for _, name := range skipped {
|
||||||
|
if !skipHostDisk(name) {
|
||||||
|
t.Fatalf("%s should be skipped", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept := []string{"sda", "nvme0n1", "mmcblk0", "vda", "sdb"}
|
||||||
|
for _, name := range kept {
|
||||||
|
if skipHostDisk(name) {
|
||||||
|
t.Fatalf("%s should be kept", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/device"
|
||||||
"vocat/internal/i18n"
|
"vocat/internal/i18n"
|
||||||
localproxy "vocat/internal/proxy"
|
localproxy "vocat/internal/proxy"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
@@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
|||||||
}
|
}
|
||||||
payload.ID = id
|
payload.ID = id
|
||||||
s.saveAndProbeUpstream(w, r, payload)
|
s.saveAndProbeUpstream(w, r, payload)
|
||||||
|
case http.MethodPatch:
|
||||||
|
var request struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, err := s.store.UpstreamProxy(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value.Enabled = request.Enabled
|
||||||
|
value.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
|
||||||
|
s.writeStoreError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bindings, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reconnectRequested := false
|
||||||
|
var reconnectErrors []string
|
||||||
|
for _, binding := range bindings {
|
||||||
|
if binding.UpstreamProxyID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||||
|
reconnectRequested = reconnectRequested || requested
|
||||||
|
if reconnectErr != nil {
|
||||||
|
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response := upstreamProxyResponse(value.Redacted())
|
||||||
|
response["reconnect_requested"] = reconnectRequested
|
||||||
|
if len(reconnectErrors) > 0 {
|
||||||
|
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
@@ -117,7 +161,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "PUT, DELETE")
|
w.Header().Set("Allow", "PUT, PATCH, DELETE")
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,8 +395,8 @@ func (s *Server) saveAndProbeUpstream(
|
|||||||
)
|
)
|
||||||
probeResponse := probeMap(probe, probeErr)
|
probeResponse := probeMap(probe, probeErr)
|
||||||
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
||||||
if probeErr == nil && probe.UDPAssociateOK {
|
if probeErr == nil && probe.UDPExchangeOK {
|
||||||
message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。")
|
message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -381,8 +425,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id
|
|||||||
8*time.Second,
|
8*time.Second,
|
||||||
)
|
)
|
||||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||||
if probeErr == nil && result.UDPAssociateOK {
|
if probeErr == nil && result.UDPExchangeOK {
|
||||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -435,8 +479,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques
|
|||||||
8*time.Second,
|
8*time.Second,
|
||||||
)
|
)
|
||||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||||
if probeErr == nil && result.UDPAssociateOK {
|
if probeErr == nil && result.UDPExchangeOK {
|
||||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
@@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var proxyCountries = []proxyCountry{
|
var namedProxyCountries = []proxyCountry{
|
||||||
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
||||||
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
||||||
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
||||||
@@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{
|
|||||||
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
||||||
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
|
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var proxyCountries = buildProxyCountries()
|
||||||
|
|
||||||
|
func buildProxyCountries() []proxyCountry {
|
||||||
|
byCode := make(map[string]proxyCountry)
|
||||||
|
for _, country := range namedProxyCountries {
|
||||||
|
byCode[country.Code] = country
|
||||||
|
}
|
||||||
|
for code, mccs := range device.MCCsByCountry() {
|
||||||
|
country, found := byCode[code]
|
||||||
|
if !found {
|
||||||
|
country = proxyCountry{Code: code, Name: code}
|
||||||
|
}
|
||||||
|
country.MCCs = append([]string(nil), mccs...)
|
||||||
|
byCode[code] = country
|
||||||
|
}
|
||||||
|
result := make([]proxyCountry, 0, len(byCode))
|
||||||
|
for _, country := range byCode {
|
||||||
|
result = append(result, country)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ type Server struct {
|
|||||||
updateApplying bool
|
updateApplying bool
|
||||||
https *httpsmode.Manager
|
https *httpsmode.Manager
|
||||||
netTraffic *liveNetTracker
|
netTraffic *liveNetTracker
|
||||||
|
hostStats *hostStatsSampler
|
||||||
publicIPMu sync.RWMutex
|
publicIPMu sync.RWMutex
|
||||||
publicIPs map[string]cachedPublicIP
|
publicIPs map[string]cachedPublicIP
|
||||||
automaticTasks *automaticTaskScheduler
|
automaticTasks *automaticTaskScheduler
|
||||||
@@ -133,6 +134,7 @@ func New(options Options) (*Server, error) {
|
|||||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||||
https: options.HTTPS,
|
https: options.HTTPS,
|
||||||
netTraffic: newLiveNetTracker(),
|
netTraffic: newLiveNetTracker(),
|
||||||
|
hostStats: newHostStatsSampler(),
|
||||||
publicIPs: make(map[string]cachedPublicIP),
|
publicIPs: make(map[string]cachedPublicIP),
|
||||||
updateCheck: update.CheckLatest,
|
updateCheck: update.CheckLatest,
|
||||||
updateApply: update.ApplyLatest,
|
updateApply: update.ApplyLatest,
|
||||||
@@ -142,6 +144,9 @@ func New(options Options) (*Server, error) {
|
|||||||
server.loadUILanguage(context.Background())
|
server.loadUILanguage(context.Background())
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", server.handleLiveness)
|
||||||
|
mux.HandleFunc("/readyz", server.handleReadiness)
|
||||||
|
mux.HandleFunc("/metrics", server.handleMetrics)
|
||||||
mux.HandleFunc("/api/health", server.handleHealth)
|
mux.HandleFunc("/api/health", server.handleHealth)
|
||||||
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
||||||
mux.HandleFunc("/api/auth/session", server.handleSession)
|
mux.HandleFunc("/api/auth/session", server.handleSession)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ var notificationChannels = []string{
|
|||||||
"bark",
|
"bark",
|
||||||
"pushplus",
|
"pushplus",
|
||||||
"wecom",
|
"wecom",
|
||||||
|
"lark",
|
||||||
}
|
}
|
||||||
|
|
||||||
var notificationFields = map[string]map[string]string{
|
var notificationFields = map[string]map[string]string{
|
||||||
@@ -65,6 +66,9 @@ var notificationFields = map[string]map[string]string{
|
|||||||
"wecom": {
|
"wecom": {
|
||||||
"urls": "strings", "payload_template": "string",
|
"urls": "strings", "payload_template": "string",
|
||||||
},
|
},
|
||||||
|
"lark": {
|
||||||
|
"url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||||
@@ -240,6 +244,20 @@ func decodeNotificationConfig(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
||||||
}
|
}
|
||||||
|
if enabled && channel == "lark" {
|
||||||
|
var resolved map[string]any
|
||||||
|
if err := json.Unmarshal(config, &resolved); err != nil {
|
||||||
|
return false, nil, fmt.Errorf("decode lark notification config: %w", err)
|
||||||
|
}
|
||||||
|
signingEnabled, _ := resolved["signing_enabled"].(bool)
|
||||||
|
if signingEnabled && configString(resolved, "url") != store.SecretMask &&
|
||||||
|
configString(resolved, "secret") == store.SecretMask {
|
||||||
|
return false, nil, errors.New("lark.secret must be re-entered when lark.url changes")
|
||||||
|
}
|
||||||
|
if err := validateLarkNotificationConfig(resolved); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return enabled, config, nil
|
return enabled, config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +283,9 @@ func validateNotificationField(
|
|||||||
if name == "text_template" {
|
if name == "text_template" {
|
||||||
limit = 32768
|
limit = 32768
|
||||||
}
|
}
|
||||||
|
if channel == "lark" && name == "payload_template" {
|
||||||
|
limit = maxLarkPayloadBytes
|
||||||
|
}
|
||||||
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
||||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
||||||
}
|
}
|
||||||
@@ -300,6 +321,16 @@ func validateNotificationField(
|
|||||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if channel == "lark" && name == "payload_template" && value != "" {
|
||||||
|
if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil {
|
||||||
|
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel == "lark" && name == "url" && value != "" && value != store.SecretMask {
|
||||||
|
if _, err := parseLarkWebhookURL(value); err != nil {
|
||||||
|
return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
case "integer":
|
case "integer":
|
||||||
var value int
|
var value int
|
||||||
if err := json.Unmarshal(raw, &value); err != nil {
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
@@ -387,7 +418,7 @@ func (s *Server) handleNotificationTest(
|
|||||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" {
|
||||||
writeError(
|
writeError(
|
||||||
w,
|
w,
|
||||||
http.StatusNotImplemented,
|
http.StatusNotImplemented,
|
||||||
@@ -441,6 +472,8 @@ func (s *Server) handleNotificationTest(
|
|||||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||||
|
case "lark":
|
||||||
|
err = sendLarkNotificationTest(notificationContext, resolved)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redacted := store.RedactText(err.Error(), provider)
|
redacted := store.RedactText(err.Error(), provider)
|
||||||
@@ -546,8 +579,8 @@ func (s *Server) resolveNotificationTestConfig(
|
|||||||
|
|
||||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||||
// settings form while allowing newly entered sensitive values in the same
|
// settings form while allowing newly entered sensitive values in the same
|
||||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
// request. Provider webhook URLs can be sensitive lists, unlike the
|
||||||
// used by the other notification channels.
|
// string-based secrets used by the other notification channels.
|
||||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
return existing
|
return existing
|
||||||
@@ -595,6 +628,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
|||||||
}
|
}
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return validateWecomNotificationConfig(config)
|
return validateWecomNotificationConfig(config)
|
||||||
|
case "lark":
|
||||||
|
return validateLarkNotificationConfig(config)
|
||||||
case "telegram":
|
case "telegram":
|
||||||
token := configString(config, "bot_token")
|
token := configString(config, "bot_token")
|
||||||
if token == "" || token == store.SecretMask {
|
if token == "" || token == store.SecretMask {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
|
func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
@@ -179,6 +179,73 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||||
|
secret := "lark-signing-secret"
|
||||||
|
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||||
|
first, err := json.Marshal(map[string]any{
|
||||||
|
"lark": map[string]any{
|
||||||
|
"enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "payload_template": template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
if bytes.Contains(recorder.Body.Bytes(), []byte("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) {
|
||||||
|
t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body)
|
||||||
|
}
|
||||||
|
response := decodeSettingsResponse(t, recorder)
|
||||||
|
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||||
|
if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask {
|
||||||
|
t.Fatalf("redacted Lark config = %#v", lark)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := json.Marshal(map[string]any{
|
||||||
|
"lark": map[string]any{
|
||||||
|
"enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": store.SecretMask, "payload_template": template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
stored, err := test.database.NotificationSetting(context.Background(), "lark")
|
||||||
|
if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) {
|
||||||
|
t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"lark": map[string]any{
|
||||||
|
"enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||||
|
"signing_enabled": false, "payload_template": template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||||
|
}
|
||||||
|
response := decodeSettingsResponse(t, recorder)
|
||||||
|
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||||
|
if _, exists := lark["secret"]; exists {
|
||||||
|
t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
raw, err := json.Marshal(map[string]any{
|
raw, err := json.Marshal(map[string]any{
|
||||||
@@ -232,6 +299,45 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) {
|
||||||
|
test := newSettingsAPITest(t)
|
||||||
|
storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored"
|
||||||
|
storedConfig, err := json.Marshal(map[string]any{
|
||||||
|
"url": storedURL,
|
||||||
|
"signing_enabled": true,
|
||||||
|
"secret": "stored-signing-secret",
|
||||||
|
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
||||||
|
Channel: "lark",
|
||||||
|
Config: storedConfig,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"url": store.SecretMask,
|
||||||
|
"signing_enabled": true,
|
||||||
|
"secret": store.SecretMask,
|
||||||
|
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved["url"] != storedURL {
|
||||||
|
t.Fatalf("resolved URL = %#v", resolved["url"])
|
||||||
|
}
|
||||||
|
if resolved["secret"] != "stored-signing-secret" {
|
||||||
|
t.Fatalf("resolved secret = %#v", resolved["secret"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||||
test := newSettingsAPITest(t)
|
test := newSettingsAPITest(t)
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -284,6 +390,36 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
|||||||
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
||||||
code: "invalid_notification_config",
|
code: "invalid_notification_config",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "invalid Lark payload template",
|
||||||
|
body: `{"lark":{"enabled":true,"payload_template":"[]"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled Lark config without webhook URL",
|
||||||
|
body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled Lark signing without secret",
|
||||||
|
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "changed Lark URL with masked signing secret",
|
||||||
|
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "insecure Lark group bot URL",
|
||||||
|
body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-Lark group bot URL",
|
||||||
|
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
||||||
|
code: "invalid_notification_config",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "null body",
|
name: "null body",
|
||||||
body: `null`,
|
body: `null`,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
|
|
||||||
const smsNotificationPollInterval = 2 * time.Second
|
const smsNotificationPollInterval = 2 * time.Second
|
||||||
|
|
||||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"}
|
||||||
|
|
||||||
type smsNotification struct {
|
type smsNotification struct {
|
||||||
DeviceID string
|
DeviceID string
|
||||||
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
|||||||
|
|
||||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||||
switch channel {
|
switch channel {
|
||||||
case "bark", "email", "webhook", "wecom":
|
case "bark", "email", "webhook", "wecom", "lark":
|
||||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -204,6 +204,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
|||||||
return sendWebhookSMSNotification(ctx, config, message)
|
return sendWebhookSMSNotification(ctx, config, message)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||||
|
case "lark":
|
||||||
|
return sendLarkNotification(ctx, config, larkSMSValues(message))
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) {
|
||||||
|
message := smsNotification{
|
||||||
|
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||||
|
Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello",
|
||||||
|
}
|
||||||
|
smsValues := larkSMSValues(message)
|
||||||
|
if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" ||
|
||||||
|
smsValues["message"] != message.Text() || smsValues["content"] != "hello" ||
|
||||||
|
smsValues["device_label"] != "EC20" {
|
||||||
|
t.Fatalf("Lark SMS values = %#v", smsValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskValues := larkAutomaticTaskValues(automaticTaskNotification{
|
||||||
|
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||||
|
})
|
||||||
|
if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" {
|
||||||
|
t.Fatalf("Lark automatic task values = %#v", taskValues)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||||
|
if taskValues[name] != "" {
|
||||||
|
t.Fatalf("%s = %q, want empty", name, taskValues[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||||
valid := map[string]map[string]any{
|
valid := map[string]map[string]any{
|
||||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||||
@@ -76,6 +101,12 @@ func TestValidateSMSNotificationConfig(t *testing.T) {
|
|||||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||||
},
|
},
|
||||||
|
"lark": {
|
||||||
|
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret",
|
||||||
|
"signing_enabled": true,
|
||||||
|
"secret": "signing-secret",
|
||||||
|
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for channel, config := range valid {
|
for channel, config := range valid {
|
||||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||||
|
|||||||
@@ -917,6 +917,56 @@ func TestNotificationArraySecretPreservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
database := openTestStore(t, ":memory:")
|
||||||
|
originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||||
|
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||||
|
Channel: "lark", Enabled: true,
|
||||||
|
Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setting, err := database.NotificationSetting(ctx, "lark")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var redacted map[string]any
|
||||||
|
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if redacted["url"] != SecretMask || redacted["secret"] != SecretMask {
|
||||||
|
t.Fatalf("redacted Lark config = %#v", redacted)
|
||||||
|
}
|
||||||
|
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||||
|
Channel: "lark", Enabled: true,
|
||||||
|
Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setting, err = database.NotificationSetting(ctx, "lark")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) {
|
||||||
|
t.Fatalf("stored Lark config = %s", setting.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) {
|
||||||
|
setting := NotificationSetting{
|
||||||
|
Config: json.RawMessage(`{"url":"","secret":""}`),
|
||||||
|
SensitiveFields: []string{"url", "secret"},
|
||||||
|
}
|
||||||
|
var redacted map[string]any
|
||||||
|
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if redacted["url"] != "" || redacted["secret"] != "" {
|
||||||
|
t.Fatalf("empty sensitive values were masked: %#v", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database := openTestStore(t, ":memory:")
|
database := openTestStore(t, ":memory:")
|
||||||
|
|||||||
@@ -610,6 +610,9 @@ func mergeJSONSecrets(
|
|||||||
func redactJSONValue(value any, replacement string) any {
|
func redactJSONValue(value any, replacement string) any {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
if typed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return replacement
|
return replacement
|
||||||
case []any:
|
case []any:
|
||||||
result := make([]any, len(typed))
|
result := make([]any, len(typed))
|
||||||
|
|||||||
+46
-17
@@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
||||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
value, err := normalizeDeviceProxyBinding(value)
|
||||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
if err != nil {
|
||||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
return err
|
||||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
|
||||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
|
||||||
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
_, err = s.db.ExecContext(ctx, `
|
||||||
createdAt := value.CreatedAt
|
|
||||||
if createdAt.IsZero() {
|
|
||||||
createdAt = now
|
|
||||||
}
|
|
||||||
updatedAt := value.UpdatedAt
|
|
||||||
if updatedAt.IsZero() {
|
|
||||||
updatedAt = now
|
|
||||||
}
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
|
||||||
INSERT INTO device_proxy_bindings (
|
INSERT INTO device_proxy_bindings (
|
||||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
@@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
|||||||
profile_name = excluded.profile_name,
|
profile_name = excluded.profile_name,
|
||||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InsertDeviceProxyBindingIfAbsent materializes a default route without ever
|
||||||
|
// replacing an explicit (or concurrently-created) ICCID binding.
|
||||||
|
func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) {
|
||||||
|
value, err := normalizeDeviceProxyBinding(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO device_proxy_bindings (
|
||||||
|
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(iccid) DO NOTHING
|
||||||
|
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err)
|
||||||
|
}
|
||||||
|
return affected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) {
|
||||||
|
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||||
|
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||||
|
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||||
|
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||||
|
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||||
|
return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if value.CreatedAt.IsZero() {
|
||||||
|
value.CreatedAt = now
|
||||||
|
}
|
||||||
|
if value.UpdatedAt.IsZero() {
|
||||||
|
value.UpdatedAt = now
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
||||||
return deviceProxyBinding(s.db.QueryRowContext(
|
return deviceProxyBinding(s.db.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
|||||||
return []string{"token"}
|
return []string{"token"}
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return []string{"urls"}
|
return []string{"urls"}
|
||||||
|
case "lark":
|
||||||
|
return []string{"url", "secret"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -94,9 +95,15 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prior, alreadyHad := parts[sequence]
|
// Some IMS stacks hand us a cumulative segment: sequence 2 contains the
|
||||||
changed = !alreadyHad || prior != segmentBody
|
// already-decoded text of sequence 1 followed by its own payload. Keep a
|
||||||
|
// snapshot so normalizing that representation remains idempotent on a later
|
||||||
|
// redelivery of the same segment.
|
||||||
|
previousParts := maps.Clone(parts)
|
||||||
|
normalizeCumulativeConcatParts(previousParts)
|
||||||
parts[sequence] = segmentBody
|
parts[sequence] = segmentBody
|
||||||
|
normalizeCumulativeConcatParts(parts)
|
||||||
|
changed = !maps.Equal(previousParts, parts)
|
||||||
|
|
||||||
sequences := make([]int, 0, len(parts))
|
sequences := make([]int, 0, len(parts))
|
||||||
for n := range parts {
|
for n := range parts {
|
||||||
@@ -130,3 +137,24 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
return joined.String(), json.RawMessage(encoded), changed, nil
|
return joined.String(), json.RawMessage(encoded), changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeCumulativeConcatParts converts cumulative IMS segment bodies back
|
||||||
|
// into ordinary per-segment bodies. It only removes an exact, non-empty prefix
|
||||||
|
// assembled from every preceding sequence starting at 1, and only when the
|
||||||
|
// current value also contains additional text. That deliberately leaves equal
|
||||||
|
// repeated segments and incomplete/out-of-order prefixes untouched.
|
||||||
|
func normalizeCumulativeConcatParts(parts map[int]string) {
|
||||||
|
var prefix strings.Builder
|
||||||
|
for sequence := 1; ; sequence++ {
|
||||||
|
text, ok := parts[sequence]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assembled := prefix.String()
|
||||||
|
if assembled != "" && len(text) > len(assembled) && strings.HasPrefix(text, assembled) {
|
||||||
|
text = strings.TrimPrefix(text, assembled)
|
||||||
|
parts[sequence] = text
|
||||||
|
}
|
||||||
|
prefix.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,62 @@ func TestMergeConcatSegmentRedeliveryIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeConcatSegmentNormalizesCumulativeIMSPart(t *testing.T) {
|
||||||
|
first := strings.Repeat("安全提醒", 17)
|
||||||
|
want := first + "请通过官方渠道核实。"
|
||||||
|
_, extra, _, err := mergeConcatSegment(nil, first, concatExtra(t, 8, 2, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, normalized, changed, err := mergeConcatSegment(extra, want, concatExtra(t, 8, 2, 2))
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("cumulative segment: body=%q changed=%v err=%v", body, changed, err)
|
||||||
|
}
|
||||||
|
if body != want {
|
||||||
|
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redelivering the cumulative wire representation must compare equal to the
|
||||||
|
// normalized stored representation and must not churn the durable row id.
|
||||||
|
body, _, changed, err = mergeConcatSegment(normalized, want, concatExtra(t, 8, 2, 2))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if changed || body != want {
|
||||||
|
t.Fatalf("redelivery: body=%q changed=%v, want %q/false", body, changed, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeConcatSegmentNormalizesCumulativeIMSPartOutOfOrder(t *testing.T) {
|
||||||
|
first := strings.Repeat("甲", 67)
|
||||||
|
want := first + "尾段"
|
||||||
|
_, extra, _, err := mergeConcatSegment(nil, want, concatExtra(t, 12, 2, 2))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _, changed, err := mergeConcatSegment(extra, first, concatExtra(t, 12, 2, 1))
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("out-of-order segment: body=%q changed=%v err=%v", body, changed, err)
|
||||||
|
}
|
||||||
|
if body != want {
|
||||||
|
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeConcatSegmentKeepsEqualRepeatedPart(t *testing.T) {
|
||||||
|
_, extra, _, err := mergeConcatSegment(nil, "重复", concatExtra(t, 13, 2, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _, _, err := mergeConcatSegment(extra, "重复", concatExtra(t, 13, 2, 2))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body != "重复重复" {
|
||||||
|
t.Fatalf("body = %q, want intentional equal segments preserved", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
||||||
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLongSMSReassemblySurvivesServiceRestart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||||
|
const (
|
||||||
|
deviceID = "dajiang"
|
||||||
|
imei = "867394042309830"
|
||||||
|
peer = "+447700900123"
|
||||||
|
)
|
||||||
|
messageID := StableConcatMessageID("ims", imei, deviceID, peer, 27, 2)
|
||||||
|
|
||||||
|
database, err := Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSaveDevice(t, database, deviceID, "大疆")
|
||||||
|
first, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第一段:安全提醒,",
|
||||||
|
Timestamp: time.Unix(1_700_000_000, 0).UTC(), Status: "received", Source: "ims",
|
||||||
|
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
|
||||||
|
t.Fatal("partial message must not be ready before restart")
|
||||||
|
}
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err = Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
second, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||||
|
Timestamp: time.Unix(1_700_000_030, 0).UTC(), Status: "received", Source: "ims",
|
||||||
|
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.Body != "第一段:安全提醒,第二段:请通过官方渠道核实。" ||
|
||||||
|
!ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
|
||||||
|
t.Fatalf("reassembled message after restart = %#v", second)
|
||||||
|
}
|
||||||
|
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: deviceID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 || messages[0].ID != second.ID {
|
||||||
|
t.Fatalf("stored messages after restart = %#v, want one merged row", messages)
|
||||||
|
}
|
||||||
|
redelivered, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||||
|
Status: "received", Source: "ims", PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if redelivered.ID != second.ID || redelivered.Body != second.Body {
|
||||||
|
t.Fatalf("redelivery duplicated or changed message: %#v", redelivered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartDeliveryReportsSurviveServiceRestart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||||
|
database, err := Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSaveDevice(t, database, "dajiang", "大疆")
|
||||||
|
extra := json.RawMessage(`{"transport":"ims","part_results":[{"reference":51},{"reference":52}]}`)
|
||||||
|
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: "ims-submit-restart", DeviceID: "dajiang", IMSI: "23433",
|
||||||
|
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
|
||||||
|
Status: "accepted_by_ims", Source: "ims", PartsTotal: 2,
|
||||||
|
DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err = Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||||
|
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||||
|
MessageReference: 51, StatusCode: 0, DeliveryState: "delivered",
|
||||||
|
})
|
||||||
|
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
|
||||||
|
t.Fatalf("first report after restart = (%#v, %v)", first, err)
|
||||||
|
}
|
||||||
|
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||||
|
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||||
|
MessageReference: 52, StatusCode: 0, DeliveryState: "delivered",
|
||||||
|
})
|
||||||
|
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
|
||||||
|
t.Fatalf("second report after restart = (%#v, %v)", second, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectSystemdUnitUsesExplicitOverride(t *testing.T) {
|
||||||
|
t.Setenv("VOCAT_SYSTEMD_UNIT", "vocat-test.service")
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if got := detectSystemdUnit(logger); got != "vocat-test.service" {
|
||||||
|
t.Fatalf("detectSystemdUnit() = %q, want vocat-test.service", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdUnitFromCgroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "cgroup v2", data: "0::/system.slice/vocat-test.service\n", want: "vocat-test.service"},
|
||||||
|
{name: "legacy", data: "1:name=systemd:/system.slice/[email protected]\n", want: "[email protected]"},
|
||||||
|
{name: "no service", data: "0::/user.slice/user-1000.slice/session-1.scope\n", want: ""},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := systemdUnitFromCgroup(test.data); got != test.want {
|
||||||
|
t.Fatalf("systemdUnitFromCgroup() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSystemdUnitRejectsArgumentsAndPaths(t *testing.T) {
|
||||||
|
for _, value := range []string{"vocat", "../vocat.service", "vocat.service --now", "vocat.service/other"} {
|
||||||
|
if validSystemdUnit.MatchString(value) {
|
||||||
|
t.Fatalf("validSystemdUnit unexpectedly accepted %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -320,17 +322,63 @@ func RestartService(logger *slog.Logger) error {
|
|||||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||||
}
|
}
|
||||||
|
unit := detectSystemdUnit(logger)
|
||||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||||
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||||
return fmt.Errorf("systemctl restart vocat: %w", err)
|
return fmt.Errorf("systemctl restart %s: %w", unit, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var validSystemdUnit = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`)
|
||||||
|
|
||||||
|
func detectSystemdUnit(logger *slog.Logger) string {
|
||||||
|
if configured := strings.TrimSpace(os.Getenv("VOCAT_SYSTEMD_UNIT")); validSystemdUnit.MatchString(configured) {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||||
|
if unit := systemdUnitFromCgroup(string(data)); unit != "" {
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Some cgroup namespaces hide the unit name. Query loaded services and
|
||||||
|
// identify the unit whose MainPID is this process before falling back.
|
||||||
|
list := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain")
|
||||||
|
if output, err := list.Output(); err == nil {
|
||||||
|
pid := strconv.Itoa(os.Getpid())
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || !validSystemdUnit.MatchString(fields[0]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
show := exec.Command("systemctl", "show", fields[0], "--property=MainPID", "--value")
|
||||||
|
if value, showErr := show.Output(); showErr == nil && strings.TrimSpace(string(value)) == pid {
|
||||||
|
return fields[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("could not identify the current systemd unit; using vocat.service", "hint", "set VOCAT_SYSTEMD_UNIT for a custom unit")
|
||||||
|
}
|
||||||
|
return "vocat.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemdUnitFromCgroup(data string) string {
|
||||||
|
for _, line := range strings.Split(data, "\n") {
|
||||||
|
for _, part := range strings.Split(line, "/") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if validSystemdUnit.MatchString(part) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// resolveDefaultTarget returns the conventional install path when present,
|
// resolveDefaultTarget returns the conventional install path when present,
|
||||||
// falling back to the running executable. This lets `vocat update` "just work"
|
// falling back to the running executable. This lets `vocat update` "just work"
|
||||||
// on the standard systemd host without flags.
|
// on the standard systemd host without flags.
|
||||||
|
|||||||
@@ -1,52 +1,569 @@
|
|||||||
package vowifi
|
package vowifi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
const att310280EPDG = "epdg.epc.att.net"
|
const (
|
||||||
|
CarrierProfileSchemaVersion = 1
|
||||||
|
CarrierProfileStandard = "standard-3gpp"
|
||||||
|
IKEProposalModern = "modern"
|
||||||
|
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||||
|
IMSProfileStandard = "standard"
|
||||||
|
IMSProfileO2Germany = "o2-germany"
|
||||||
|
IMSProfileATT = "att"
|
||||||
|
)
|
||||||
|
|
||||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
// CarrierProfile contains only interoperability choices that cannot be
|
||||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
// reliably discovered from the SIM or negotiated with the network. All
|
||||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
// protocol layers consume this common result so their carrier handling cannot
|
||||||
// VoWiFi access gateway.
|
// drift into separate MCC/MNC switch statements.
|
||||||
|
type CarrierProfile struct {
|
||||||
|
ID string
|
||||||
|
MatchSource string
|
||||||
|
RouteMCC string
|
||||||
|
RouteMNC string
|
||||||
|
EPDG string
|
||||||
|
IKEProposal string
|
||||||
|
AdvertiseEAPOnly bool
|
||||||
|
IMSTransport string
|
||||||
|
IMSIdentityProfile string
|
||||||
|
IMSRegisterProfile string
|
||||||
|
IMSIPSecEncryption string
|
||||||
|
SMSCenter string
|
||||||
|
PANICountry string
|
||||||
|
PANINode string
|
||||||
|
IMSDialURIScheme string
|
||||||
|
IMSUserEqPhone bool
|
||||||
|
IMSVoiceCodecs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileDocument struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Profiles []carrierProfileRule `json:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Match carrierProfileMatch `json:"match,omitzero"`
|
||||||
|
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
||||||
|
Route carrierProfileRoute `json:"route,omitzero"`
|
||||||
|
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
||||||
|
IKE carrierProfileIKE `json:"ike,omitzero"`
|
||||||
|
IMS carrierProfileIMS `json:"ims,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileMatch struct {
|
||||||
|
HomePLMNs []string `json:"home_plmns,omitempty"`
|
||||||
|
IMSIPrefixes []string `json:"imsi_prefixes,omitempty"`
|
||||||
|
ICCIDPrefixes []string `json:"iccid_prefixes,omitempty"`
|
||||||
|
SPNs []string `json:"spns,omitempty"`
|
||||||
|
GID1Prefixes []string `json:"gid1_prefixes,omitempty"`
|
||||||
|
GID2Prefixes []string `json:"gid2_prefixes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileRoute struct {
|
||||||
|
MCC string `json:"mcc,omitempty"`
|
||||||
|
MNC string `json:"mnc,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileEPDG struct {
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
DNSHosts []string `json:"dns_hosts,omitempty"`
|
||||||
|
DNSClientSubnet string `json:"dns_client_subnet,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileIKE struct {
|
||||||
|
Proposal string `json:"proposal,omitempty"`
|
||||||
|
AdvertiseEAPOnly *bool `json:"advertise_eap_only,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileIMS struct {
|
||||||
|
Transport string `json:"transport,omitempty"`
|
||||||
|
IdentityProfile string `json:"identity_profile,omitempty"`
|
||||||
|
RegisterProfile string `json:"register_profile,omitempty"`
|
||||||
|
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
||||||
|
SMSCenter string `json:"sms_center,omitempty"`
|
||||||
|
PANICountry string `json:"pani_country,omitempty"`
|
||||||
|
PANINode string `json:"pani_node,omitempty"`
|
||||||
|
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
||||||
|
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
||||||
|
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed carrier_profiles.json
|
||||||
|
var carrierProfilesJSON []byte
|
||||||
|
|
||||||
|
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||||
|
|
||||||
|
var externalCarrierProfiles = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
rules []carrierProfileRule
|
||||||
|
}{}
|
||||||
|
|
||||||
|
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||||
|
rules, err := loadCarrierProfiles(encoded)
|
||||||
|
if err != nil {
|
||||||
|
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCarrierProfiles(encoded []byte) ([]carrierProfileRule, error) {
|
||||||
|
var document carrierProfileDocument
|
||||||
|
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if document.Version != CarrierProfileSchemaVersion {
|
||||||
|
return nil, fmt.Errorf("unsupported carrier profile version %d", document.Version)
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(document.Profiles))
|
||||||
|
for index := range document.Profiles {
|
||||||
|
rule := &document.Profiles[index]
|
||||||
|
rule.ID = strings.TrimSpace(rule.ID)
|
||||||
|
if rule.ID == "" {
|
||||||
|
return nil, fmt.Errorf("carrier profile %d ID is empty", index)
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[rule.ID]; duplicate {
|
||||||
|
return nil, errors.New("duplicate carrier profile " + rule.ID)
|
||||||
|
}
|
||||||
|
seen[rule.ID] = struct{}{}
|
||||||
|
if !validCarrierProfileRule(*rule) {
|
||||||
|
return nil, errors.New("invalid carrier profile " + rule.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document.Profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCarrierProfileDirectory replaces the installed profile set with all
|
||||||
|
// valid JSON documents in dir. A missing directory is an empty set. Profiles
|
||||||
|
// are sorted by filename; later profiles win only when selector specificity is
|
||||||
|
// equal, so a broad installed PLMN rule cannot hide a constrained MVNO rule.
|
||||||
|
func LoadCarrierProfileDirectory(dir string) error {
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
return errors.New("carrier profile directory is empty")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
externalCarrierProfiles.Lock()
|
||||||
|
externalCarrierProfiles.rules = nil
|
||||||
|
externalCarrierProfiles.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read carrier profile directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
if len(entries) > 256 {
|
||||||
|
return fmt.Errorf("carrier profile directory %q contains %d entries; maximum is 256", dir, len(entries))
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||||
|
loaded := make([]carrierProfileRule, 0, len(entries))
|
||||||
|
seen := make(map[string]string)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat carrier profile %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if info.Size() > 1<<20 {
|
||||||
|
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open carrier profile %q: %w", path, err)
|
||||||
|
}
|
||||||
|
encoded, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||||
|
closeErr := file.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("read carrier profile %q: %w", path, readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("close carrier profile %q: %w", path, closeErr)
|
||||||
|
}
|
||||||
|
if len(encoded) > 1<<20 {
|
||||||
|
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||||
|
}
|
||||||
|
rules, err := loadCarrierProfiles(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load carrier profile %q: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if previous := seen[rule.ID]; previous != "" {
|
||||||
|
return fmt.Errorf("carrier profile %q is duplicated in %q and %q", rule.ID, previous, path)
|
||||||
|
}
|
||||||
|
seen[rule.ID] = path
|
||||||
|
loaded = append(loaded, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
externalCarrierProfiles.Lock()
|
||||||
|
externalCarrierProfiles.rules = loaded
|
||||||
|
externalCarrierProfiles.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func carrierProfilesSnapshot() []carrierProfileRule {
|
||||||
|
externalCarrierProfiles.RLock()
|
||||||
|
defer externalCarrierProfiles.RUnlock()
|
||||||
|
result := make([]carrierProfileRule, 0, len(builtinCarrierProfiles)+len(externalCarrierProfiles.rules))
|
||||||
|
result = append(result, builtinCarrierProfiles...)
|
||||||
|
result = append(result, externalCarrierProfiles.rules...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||||
|
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 _, match := range matches {
|
||||||
|
if emptyCarrierProfileMatch(match) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, plmn := range match.HomePLMNs {
|
||||||
|
if canonicalPLMNValue(plmn) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range match.IMSIPrefixes {
|
||||||
|
if len(prefix) < 5 || len(prefix) > 18 || !decimalString(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range match.ICCIDPrefixes {
|
||||||
|
if len(prefix) < 5 || len(prefix) > 22 || !decimalString(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range append(append([]string(nil), match.GID1Prefixes...), match.GID2Prefixes...) {
|
||||||
|
if len(prefix) < 1 || len(prefix) > 64 || !hexString(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, spn := range match.SPNs {
|
||||||
|
if strings.TrimSpace(spn) == "" || len(spn) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
||||||
|
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if proposal := strings.TrimSpace(rule.IKE.Proposal); proposal != "" &&
|
||||||
|
proposal != IKEProposalModern && proposal != IKEProposalLegacy {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if transport := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); transport != "" &&
|
||||||
|
transport != "tcp" && transport != "udp" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if encryption := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); encryption != "" &&
|
||||||
|
encryption != "aes-cbc" && encryption != "null" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
||||||
|
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, codec := range rule.IMS.VoiceCodecs {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(codec)) {
|
||||||
|
case "PCMA", "PCMU", "AMR", "AMR-WB":
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyCarrierProfileMatch(match carrierProfileMatch) bool {
|
||||||
|
return len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||||
|
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexString(value string) bool {
|
||||||
|
for _, item := range value {
|
||||||
|
if item >= '0' && item <= '9' || item >= 'a' && item <= 'f' || item >= 'A' && item <= 'F' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return value != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalString(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range value {
|
||||||
|
if item < '0' || item > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||||
|
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||||
|
// PLMN without weakening the default match for unrelated subscriptions.
|
||||||
|
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||||
|
resolved := CarrierProfile{
|
||||||
|
ID: CarrierProfileStandard,
|
||||||
|
MatchSource: "standard",
|
||||||
|
IKEProposal: IKEProposalModern,
|
||||||
|
AdvertiseEAPOnly: true,
|
||||||
|
IMSIdentityProfile: IMSProfileStandard,
|
||||||
|
IMSRegisterProfile: IMSProfileStandard,
|
||||||
|
IMSIPSecEncryption: "aes-cbc",
|
||||||
|
IMSDialURIScheme: "tel",
|
||||||
|
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||||
|
}
|
||||||
|
bestScore := -1
|
||||||
|
for _, rule := range carrierProfilesSnapshot() {
|
||||||
|
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||||
|
if !matched || score < bestScore {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bestScore = score
|
||||||
|
resolved = applyCarrierProfileRule(resolved, rule, source)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if len(match.HomePLMNs) > 0 {
|
||||||
|
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||||
|
if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool {
|
||||||
|
return canonicalPLMNValue(value) == wanted
|
||||||
|
}) {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
score += 100
|
||||||
|
sources = append(sources, "hplmn")
|
||||||
|
}
|
||||||
|
for _, selector := range []struct {
|
||||||
|
name string
|
||||||
|
weight int
|
||||||
|
values []string
|
||||||
|
actual string
|
||||||
|
foldCase bool
|
||||||
|
}{
|
||||||
|
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
|
||||||
|
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
|
||||||
|
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
|
||||||
|
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
|
||||||
|
} {
|
||||||
|
if len(selector.values) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
actual := strings.TrimSpace(selector.actual)
|
||||||
|
if actual == "" || !matchesAny(selector.values, func(prefix string) bool {
|
||||||
|
prefix = strings.TrimSpace(prefix)
|
||||||
|
if selector.foldCase {
|
||||||
|
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(actual, prefix)
|
||||||
|
}) {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
score += selector.weight
|
||||||
|
sources = append(sources, selector.name)
|
||||||
|
}
|
||||||
|
if len(match.SPNs) > 0 {
|
||||||
|
spn := strings.TrimSpace(identity.SPN)
|
||||||
|
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||||
|
}) {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
score += 20
|
||||||
|
sources = append(sources, "spn")
|
||||||
|
}
|
||||||
|
return score, strings.Join(sources, "+"), score > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAny(values []string, match func(string) bool) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if match(value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
|
||||||
|
base.ID = rule.ID
|
||||||
|
base.MatchSource = source
|
||||||
|
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
||||||
|
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
||||||
|
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
||||||
|
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
||||||
|
base.IKEProposal = value
|
||||||
|
}
|
||||||
|
if rule.IKE.AdvertiseEAPOnly != nil {
|
||||||
|
base.AdvertiseEAPOnly = *rule.IKE.AdvertiseEAPOnly
|
||||||
|
}
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); value != "" {
|
||||||
|
base.IMSTransport = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.IMS.IdentityProfile); value != "" {
|
||||||
|
base.IMSIdentityProfile = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(rule.IMS.RegisterProfile); value != "" {
|
||||||
|
base.IMSRegisterProfile = value
|
||||||
|
}
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); value != "" {
|
||||||
|
base.IMSIPSecEncryption = value
|
||||||
|
}
|
||||||
|
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||||
|
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
||||||
|
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
||||||
|
base.IMSDialURIScheme = value
|
||||||
|
}
|
||||||
|
if rule.IMS.UserEqPhone != nil {
|
||||||
|
base.IMSUserEqPhone = *rule.IMS.UserEqPhone
|
||||||
|
}
|
||||||
|
if len(rule.IMS.VoiceCodecs) > 0 {
|
||||||
|
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeVoiceCodecs(values []string) []string {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[value]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalPLMN(mcc, mnc string) string {
|
||||||
|
mcc = strings.TrimSpace(mcc)
|
||||||
|
mnc = strings.TrimSpace(mnc)
|
||||||
|
if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for len(mnc) < 3 {
|
||||||
|
mnc = "0" + mnc
|
||||||
|
}
|
||||||
|
return mcc + mnc
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalPLMNValue(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.ReplaceAll(value, "/", ""))
|
||||||
|
if len(value) != 5 && len(value) != 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return canonicalPLMN(value[:3], value[3:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignedRoutePLMN remains available to callers that only have the legacy
|
||||||
|
// identifier pair. New code resolves the complete SIMIdentity so SPN/GID
|
||||||
|
// selectors can participate.
|
||||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||||
iccid = strings.TrimSpace(iccid)
|
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
|
||||||
imsi = strings.TrimSpace(imsi)
|
if len(identity.IMSI) >= 5 {
|
||||||
switch {
|
identity.HomeMCC = identity.IMSI[:3]
|
||||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
for _, length := range []int{3, 2} {
|
||||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
if len(identity.IMSI) < 3+length {
|
||||||
return "234", "15", true
|
continue
|
||||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
}
|
||||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
identity.HomeMNC = identity.IMSI[3 : 3+length]
|
||||||
// the initial VoWiFi provisioning path.
|
profile := ResolveCarrierProfile(identity)
|
||||||
return "234", "30", true
|
if profile.RouteMCC != "" {
|
||||||
default:
|
return profile.RouteMCC, profile.RouteMNC, true
|
||||||
return "", "", false
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
|
||||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
|
||||||
// drift between protocol layers.
|
|
||||||
func IsATT310280(identity SIMIdentity) bool {
|
func IsATT310280(identity SIMIdentity) bool {
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
|
||||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
|
||||||
imsi := strings.TrimSpace(identity.IMSI)
|
|
||||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||||
if strings.TrimSpace(identity.EPDG) != "" {
|
if strings.TrimSpace(identity.EPDG) != "" {
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
profile := ResolveCarrierProfile(identity)
|
||||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
switch {
|
||||||
|
case profile.EPDG != "":
|
||||||
|
identity.EPDG = profile.EPDG
|
||||||
|
case profile.RouteMCC != "":
|
||||||
|
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||||
}
|
}
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||||
|
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||||
|
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||||
|
func EPDGDNSClientSubnet(host string) string {
|
||||||
|
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
|
for _, rule := range carrierProfilesSnapshot() {
|
||||||
|
for _, candidate := range rule.EPDG.DNSHosts {
|
||||||
|
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
||||||
|
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func standardEPDGHostname(mcc, mnc string) string {
|
func standardEPDGHostname(mcc, mnc string) string {
|
||||||
mnc = strings.TrimSpace(mnc)
|
mnc = strings.TrimSpace(mnc)
|
||||||
for len(mnc) < 3 {
|
for len(mnc) < 3 {
|
||||||
|
|||||||
@@ -54,3 +54,94 @@ func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||||
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
||||||
|
})
|
||||||
|
if profile.ID != CarrierProfileStandard || profile.MatchSource != "standard" {
|
||||||
|
t.Fatalf("default profile = %#v", profile)
|
||||||
|
}
|
||||||
|
if profile.IKEProposal != IKEProposalModern || !profile.AdvertiseEAPOnly ||
|
||||||
|
profile.IMSIdentityProfile != IMSProfileStandard || profile.IMSRegisterProfile != IMSProfileStandard {
|
||||||
|
t.Fatalf("default profile lost standard capabilities: %#v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
||||||
|
profile := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
||||||
|
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
||||||
|
})
|
||||||
|
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
||||||
|
t.Fatalf("MVNO profile = %#v", profile)
|
||||||
|
}
|
||||||
|
if profile.MatchSource != "hplmn+imsi+iccid" {
|
||||||
|
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
||||||
|
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
||||||
|
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
||||||
|
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
||||||
|
}
|
||||||
|
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
||||||
|
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"id": "xesim-lebara-vodafone-uk",
|
||||||
|
"match": {
|
||||||
|
"home_plmns": ["20404"],
|
||||||
|
"imsi_prefixes": ["204047"],
|
||||||
|
"iccid_prefixes": ["894416"]
|
||||||
|
},
|
||||||
|
"route": { "mcc": "234", "mnc": "15" },
|
||||||
|
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ee-uk-hosted-23433",
|
||||||
|
"match": {
|
||||||
|
"home_plmns": ["23433"],
|
||||||
|
"imsi_prefixes": ["23433"],
|
||||||
|
"iccid_prefixes": ["894430"]
|
||||||
|
},
|
||||||
|
"route": { "mcc": "234", "mnc": "30" },
|
||||||
|
"ims": {
|
||||||
|
"pani_country": "GB",
|
||||||
|
"pani_node": "000000000000",
|
||||||
|
"dial_uri_scheme": "sip",
|
||||||
|
"voice_codecs": ["PCMA", "PCMU"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "att-us",
|
||||||
|
"match": {
|
||||||
|
"home_plmns": ["310280"],
|
||||||
|
"imsi_prefixes": ["310280"]
|
||||||
|
},
|
||||||
|
"epdg": { "hostname": "epdg.epc.att.net" },
|
||||||
|
"ims": {
|
||||||
|
"identity_profile": "att",
|
||||||
|
"register_profile": "att",
|
||||||
|
"ipsec_encryption": "aes-cbc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "o2-germany",
|
||||||
|
"match": { "home_plmns": ["26203"] },
|
||||||
|
"ike": { "advertise_eap_only": false },
|
||||||
|
"ims": {
|
||||||
|
"register_profile": "o2-germany",
|
||||||
|
"ipsec_encryption": "null"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vodafone-uk",
|
||||||
|
"match": { "home_plmns": ["23415"] },
|
||||||
|
"ike": { "proposal": "legacy-sha1-modp1024" },
|
||||||
|
"ims": { "sms_center": "+447785016005" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vodafone-netherlands",
|
||||||
|
"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",
|
||||||
|
"user_eq_phone": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vodafone-germany",
|
||||||
|
"match": { "home_plmns": ["26202"] },
|
||||||
|
"epdg": {
|
||||||
|
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
|
||||||
|
"dns_client_subnet": "109.192.0.0/24"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -173,6 +173,13 @@ func (adapter *EC20Adapter) ReadIdentity(
|
|||||||
HomeMCC: homeMCC,
|
HomeMCC: homeMCC,
|
||||||
HomeMNC: homeMNC,
|
HomeMNC: homeMNC,
|
||||||
}
|
}
|
||||||
|
if reader, ok := adapter.executor.(SIMMetadataReader); ok {
|
||||||
|
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||||
|
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||||
|
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||||
|
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||||
|
}
|
||||||
|
}
|
||||||
identity = applyAssignedCarrierRoute(identity)
|
identity = applyAssignedCarrierRoute(identity)
|
||||||
adapter.mu.Lock()
|
adapter.mu.Lock()
|
||||||
adapter.bindings[iccid] = ec20SIMBinding{
|
adapter.bindings[iccid] = ec20SIMBinding{
|
||||||
|
|||||||
@@ -10,19 +10,12 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const googleDNSOverHTTPS = "https://dns.google/resolve"
|
const googleDNSOverHTTPS = "https://dns.google/resolve"
|
||||||
|
|
||||||
// A small number of operators publish the standard ePDG CNAME globally but
|
|
||||||
// return its A records only when the recursive DNS query appears to originate
|
|
||||||
// in the home country. Keep this list deliberately narrow: ordinary ePDGs must
|
|
||||||
// continue to use the host resolver, and a fallback is attempted only after
|
|
||||||
// that resolver has failed.
|
|
||||||
var geoRestrictedEPDGSubnets = map[string]string{
|
|
||||||
"epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany
|
|
||||||
}
|
|
||||||
|
|
||||||
type dnsOverHTTPSResponse struct {
|
type dnsOverHTTPSResponse struct {
|
||||||
Status int `json:"Status"`
|
Status int `json:"Status"`
|
||||||
Answer []struct {
|
Answer []struct {
|
||||||
@@ -41,7 +34,7 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
subnet := geoRestrictedEPDGSubnets[normalized]
|
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||||
if subnet == "" {
|
if subnet == "" {
|
||||||
if systemErr != nil {
|
if systemErr != nil {
|
||||||
return nil, systemErr
|
return nil, systemErr
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -20,17 +21,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Random io.Reader
|
Random io.Reader
|
||||||
Resolver *net.Resolver
|
Resolver *net.Resolver
|
||||||
Dialer *net.Dialer
|
Dialer *net.Dialer
|
||||||
RootCAs *x509.CertPool
|
RootCAs *x509.CertPool
|
||||||
ResponderPublicKey crypto.PublicKey
|
ResponderPublicKey crypto.PublicKey
|
||||||
ServerName string
|
ServerName string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
Installer ChildSAInstaller
|
Installer ChildSAInstaller
|
||||||
IdentityType uint8
|
IdentityType uint8
|
||||||
APN string
|
APN string
|
||||||
|
AutoProposalFallback bool
|
||||||
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
@@ -42,6 +45,9 @@ func NewProvider(config Config) (*Provider, error) {
|
|||||||
if config.Random == nil {
|
if config.Random == nil {
|
||||||
config.Random = rand.Reader
|
config.Random = rand.Reader
|
||||||
}
|
}
|
||||||
|
if config.Logger == nil {
|
||||||
|
config.Logger = slog.Default()
|
||||||
|
}
|
||||||
if config.Resolver == nil {
|
if config.Resolver == nil {
|
||||||
config.Resolver = net.DefaultResolver
|
config.Resolver = net.DefaultResolver
|
||||||
}
|
}
|
||||||
@@ -77,6 +83,30 @@ func NewProvider(config Config) (*Provider, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
|
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
|
||||||
|
if provider == nil {
|
||||||
|
return nil, errors.New("ike: nil provider")
|
||||||
|
}
|
||||||
|
session, err := provider.start(ctx, request, false)
|
||||||
|
if err == nil || !provider.config.AutoProposalFallback {
|
||||||
|
return session, err
|
||||||
|
}
|
||||||
|
profile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||||
|
if profile.ID != vowifi.CarrierProfileStandard || !retryableLegacyProposal(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
provider.config.Logger.Warn("IKE ePDG rejected modern proposal; trying bounded legacy fallback",
|
||||||
|
"carrier_profile", profile.ID, "from_proposal", vowifi.IKEProposalModern,
|
||||||
|
"to_proposal", vowifi.IKEProposalLegacy, "error", err)
|
||||||
|
session, fallbackErr := provider.start(ctx, request, true)
|
||||||
|
if fallbackErr != nil {
|
||||||
|
return nil, errors.Join(err, fmt.Errorf("ike: legacy proposal fallback failed: %w", fallbackErr))
|
||||||
|
}
|
||||||
|
provider.config.Logger.Info("IKE automatic legacy proposal fallback succeeded",
|
||||||
|
"carrier_profile", profile.ID, "proposal", vowifi.IKEProposalLegacy)
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) start(ctx context.Context, request vowifi.TunnelRequest, forceLegacy bool) (vowifi.TunnelSession, error) {
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
return nil, errors.New("ike: nil provider")
|
return nil, errors.New("ike: nil provider")
|
||||||
}
|
}
|
||||||
@@ -110,8 +140,12 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
group := uint16(dhMODP2048)
|
group := uint16(dhMODP2048)
|
||||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
carrierProfile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
legacyFirst := carrierProfile.IKEProposal == vowifi.IKEProposalLegacy
|
||||||
|
if forceLegacy {
|
||||||
|
legacyFirst = true
|
||||||
|
}
|
||||||
|
advertiseEAPOnly := carrierProfile.AdvertiseEAPOnly
|
||||||
if legacyFirst {
|
if legacyFirst {
|
||||||
group = dhMODP1024
|
group = dhMODP1024
|
||||||
}
|
}
|
||||||
@@ -582,30 +616,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func legacyIKEProfile(mcc, mnc string) bool {
|
|
||||||
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
|
|
||||||
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
|
|
||||||
// that Vodafone NL core; treating them as a generic modern network causes
|
|
||||||
// IKE_SA_INIT to fail before EAP-AKA even begins.
|
|
||||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
|
||||||
return plmn == "23415" || plmn == "2044"
|
|
||||||
}
|
|
||||||
|
|
||||||
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
|
|
||||||
// Android exposes the ePDG authentication method as carrier policy rather
|
|
||||||
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
|
|
||||||
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
|
|
||||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
|
||||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
|
||||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
|
||||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
|
||||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
|
||||||
return plmn == "2623"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildInitialEAPAuth(
|
func buildInitialEAPAuth(
|
||||||
idi payload,
|
idi payload,
|
||||||
requestedIDr payload,
|
requestedIDr payload,
|
||||||
@@ -719,6 +729,27 @@ func decryptAndValidate(
|
|||||||
return header, payloads, nil
|
return header, payloads, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||||
|
|
||||||
|
type invalidKEPayloadError struct {
|
||||||
|
group uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *invalidKEPayloadError) Error() string {
|
||||||
|
if err.group != 0 {
|
||||||
|
return fmt.Sprintf("ike: responder requires DH group %d", err.group)
|
||||||
|
}
|
||||||
|
return "ike: responder reported INVALID_KE_PAYLOAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryableLegacyProposal(err error) bool {
|
||||||
|
if errors.Is(err, errNoProposalChosen) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var invalidKE *invalidKEPayloadError
|
||||||
|
return errors.As(err, &invalidKE) && (invalidKE.group == 0 || invalidKE.group == dhMODP1024)
|
||||||
|
}
|
||||||
|
|
||||||
func rejectFatalNotifications(payloads []payload) error {
|
func rejectFatalNotifications(payloads []payload) error {
|
||||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||||
kind, data, err := parseNotify(item)
|
kind, data, err := parseNotify(item)
|
||||||
@@ -727,12 +758,12 @@ func rejectFatalNotifications(payloads []payload) error {
|
|||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
case notifyNoProposal:
|
case notifyNoProposal:
|
||||||
return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
return errNoProposalChosen
|
||||||
case notifyInvalidKE:
|
case notifyInvalidKE:
|
||||||
if len(data) == 2 {
|
if len(data) == 2 {
|
||||||
return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data))
|
return &invalidKEPayloadError{group: binary.BigEndian.Uint16(data)}
|
||||||
}
|
}
|
||||||
return errors.New("ike: responder reported INVALID_KE_PAYLOAD")
|
return &invalidKEPayloadError{}
|
||||||
}
|
}
|
||||||
if kind < 16384 {
|
if kind < 16384 {
|
||||||
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
|
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
|
||||||
|
|||||||
@@ -25,15 +25,37 @@ func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
|||||||
{mcc: "204", mnc: "04"},
|
{mcc: "204", mnc: "04"},
|
||||||
{mcc: "204", mnc: "004"},
|
{mcc: "204", mnc: "004"},
|
||||||
} {
|
} {
|
||||||
if !legacyIKEProfile(item.mcc, item.mnc) {
|
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
||||||
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
|
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
||||||
|
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if legacyIKEProfile("234", "87") {
|
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
||||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||||
|
for _, err := range []error{
|
||||||
|
errNoProposalChosen,
|
||||||
|
&invalidKEPayloadError{},
|
||||||
|
&invalidKEPayloadError{group: dhMODP1024},
|
||||||
|
} {
|
||||||
|
if !retryableLegacyProposal(err) {
|
||||||
|
t.Errorf("negotiation failure %v was not retryable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, err := range []error{
|
||||||
|
&invalidKEPayloadError{group: dhMODP2048},
|
||||||
|
errors.New("ike: authentication failed"),
|
||||||
|
vowifi.ErrEAPAuthenticationRejected,
|
||||||
|
} {
|
||||||
|
if retryableLegacyProposal(err) {
|
||||||
|
t.Errorf("unsafe failure %v enabled legacy retry", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (reader constantReader) Read(destination []byte) (int, error) {
|
func (reader constantReader) Read(destination []byte) (int, error) {
|
||||||
for index := range destination {
|
for index := range destination {
|
||||||
destination[index] = reader.value
|
destination[index] = reader.value
|
||||||
|
|||||||
@@ -171,11 +171,12 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
|||||||
|
|
||||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||||
for _, mnc := range []string{"03", "003"} {
|
for _, mnc := range []string{"03", "003"} {
|
||||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
||||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
||||||
|
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
||||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,30 @@ var (
|
|||||||
|
|
||||||
const terminalCallRetention = 30 * time.Second
|
const terminalCallRetention = 30 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
mmtelServiceURN = "urn:urn-7:3gpp-service.ims.icsi.mmtel"
|
||||||
|
mmtelFeatureTag = "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"
|
||||||
|
)
|
||||||
|
|
||||||
type imsCall struct {
|
type imsCall struct {
|
||||||
public vowifi.Call
|
public vowifi.Call
|
||||||
callID string
|
callID string
|
||||||
target string
|
target string
|
||||||
from string
|
from string
|
||||||
to string
|
to string
|
||||||
branch string
|
branch string
|
||||||
cseq uint32
|
cseq uint32
|
||||||
invite *sipRequest
|
inviteTarget string
|
||||||
respond func([]byte) error
|
invite *sipRequest
|
||||||
responses chan *sipResponse
|
respond func([]byte) error
|
||||||
remoteTag string
|
responses chan *sipResponse
|
||||||
routes []string
|
remoteTag string
|
||||||
terminated bool
|
routes []string
|
||||||
media *rtpMedia
|
terminated bool
|
||||||
|
media *rtpMedia
|
||||||
|
pracked map[string]bool
|
||||||
|
sessionExpires int
|
||||||
|
sessionCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) Calls() []vowifi.Call {
|
func (session *Session) Calls() []vowifi.Call {
|
||||||
@@ -67,12 +76,14 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
callID := callToken + "@" + addressHost(session.conn.LocalAddr())
|
callID := callToken + "@" + addressHost(session.conn.LocalAddr())
|
||||||
target := "tel:" + number
|
carrierProfile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||||
|
target := callTargetURI(number, session.identity.domain, carrierProfile)
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
cseq := session.cseq
|
cseq := session.cseq
|
||||||
session.cseq++
|
session.cseq++
|
||||||
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
||||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
fromIdentity, preferredIdentity, identitySource := session.callOriginatingIdentitiesLocked(carrierProfile)
|
||||||
session.mu.Unlock()
|
session.mu.Unlock()
|
||||||
media, err := newRTPMedia(session.localMediaIP())
|
media, err := newRTPMedia(session.localMediaIP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,7 +91,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
}
|
}
|
||||||
body := media.offerSDP(session.localMediaIP())
|
body := media.offerSDP(session.localMediaIP())
|
||||||
transportUpper := strings.ToUpper(session.transport)
|
transportUpper := strings.ToUpper(session.transport)
|
||||||
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
from := "<" + fromIdentity + ">;tag=" + session.fromTag
|
||||||
to := "<" + target + ">"
|
to := "<" + target + ">"
|
||||||
lines := []string{
|
lines := []string{
|
||||||
"INVITE " + target + " SIP/2.0",
|
"INVITE " + target + " SIP/2.0",
|
||||||
@@ -100,10 +111,17 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
"To: "+to,
|
"To: "+to,
|
||||||
"Call-ID: "+callID,
|
"Call-ID: "+callID,
|
||||||
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
||||||
"Contact: <sip:"+session.identity.user+"@"+session.contactAddress()+";transport="+session.transport+">",
|
session.dialogContactHeader(),
|
||||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
"P-Preferred-Identity: <"+preferredIdentity+">",
|
||||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE",
|
"P-Preferred-Service: "+mmtelServiceURN,
|
||||||
"Supported: timer",
|
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
|
||||||
|
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
|
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
|
||||||
|
"Supported: 100rel, timer, replaces",
|
||||||
|
"Session-Expires: 1800;refresher=uac",
|
||||||
|
"Min-SE: 90",
|
||||||
|
"Accept: application/sdp",
|
||||||
"Content-Type: application/sdp",
|
"Content-Type: application/sdp",
|
||||||
"Content-Length: "+strconv.Itoa(len(body)), "", "",
|
"Content-Length: "+strconv.Itoa(len(body)), "", "",
|
||||||
)
|
)
|
||||||
@@ -120,12 +138,19 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
session.transactionsMu.Unlock()
|
session.transactionsMu.Unlock()
|
||||||
call := &imsCall{
|
call := &imsCall{
|
||||||
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
||||||
callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
callID: callID, target: target, inviteTarget: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||||
routes: routes, media: media,
|
routes: routes, media: media, pracked: make(map[string]bool),
|
||||||
}
|
}
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
|
if session.provider != nil && session.provider.config.Logger != nil {
|
||||||
|
session.provider.config.Logger.Info("IMS call started",
|
||||||
|
"direction", "outgoing",
|
||||||
|
"identity_source", identitySource,
|
||||||
|
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||||
|
)
|
||||||
|
}
|
||||||
session.writeMu.Lock()
|
session.writeMu.Lock()
|
||||||
_, err = session.conn.Write(request)
|
_, err = session.conn.Write(request)
|
||||||
session.writeMu.Unlock()
|
session.writeMu.Unlock()
|
||||||
@@ -166,24 +191,26 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
|||||||
if response == nil {
|
if response == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
diagnostic := callResponseDiagnostic(response)
|
||||||
|
session.logCallResponse(response, diagnostic)
|
||||||
if response.StatusCode < 200 {
|
if response.StatusCode < 200 {
|
||||||
session.setCallDiagnostic(call.callID, response.StatusCode, response.Reason)
|
session.setCallDiagnostic(call.callID, response.StatusCode, diagnostic)
|
||||||
if response.StatusCode >= 180 {
|
session.updateCallDialogFromResponse(call, response)
|
||||||
|
if len(response.Body) > 0 {
|
||||||
|
if mediaErr := call.media.configureRemote(response.Body); mediaErr == nil {
|
||||||
|
session.setCallMediaReady(call.callID)
|
||||||
|
session.setCallState(call.callID, "early_media")
|
||||||
|
}
|
||||||
|
} else if response.StatusCode >= 180 {
|
||||||
session.setCallState(call.callID, "ringing")
|
session.setCallState(call.callID, "ringing")
|
||||||
}
|
}
|
||||||
|
if reliableProvisional(response) {
|
||||||
|
go session.sendPRACK(call, response)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||||
session.callMu.Lock()
|
session.updateCallDialogFromResponse(call, response)
|
||||||
call.to = response.value("To")
|
|
||||||
call.remoteTag = headerParameter(call.to, "tag")
|
|
||||||
if contact := headerURI(response.value("Contact")); contact != "" {
|
|
||||||
call.target = contact
|
|
||||||
}
|
|
||||||
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
|
||||||
call.routes = reverseStrings(recordRoutes)
|
|
||||||
}
|
|
||||||
session.callMu.Unlock()
|
|
||||||
mediaErr := call.media.configureRemote(response.Body)
|
mediaErr := call.media.configureRemote(response.Body)
|
||||||
_ = session.sendACK(call)
|
_ = session.sendACK(call)
|
||||||
if mediaErr != nil {
|
if mediaErr != nil {
|
||||||
@@ -197,13 +224,23 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
|||||||
}
|
}
|
||||||
session.setCallMediaReady(call.callID)
|
session.setCallMediaReady(call.callID)
|
||||||
session.setCallState(call.callID, "active")
|
session.setCallState(call.callID, "active")
|
||||||
} else if session.callWasTerminated(call.callID) {
|
session.startSessionTimer(call, response.value("Session-Expires"))
|
||||||
// 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)
|
|
||||||
} else {
|
} 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
|
return
|
||||||
}
|
}
|
||||||
@@ -223,7 +260,13 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
|||||||
}
|
}
|
||||||
request, respond := call.invite, call.respond
|
request, respond := call.invite, call.respond
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, call.media.answerSDP(session.localMediaIP()))
|
response, err := buildSIPResponseWithBody(
|
||||||
|
request,
|
||||||
|
200,
|
||||||
|
session.fromTag,
|
||||||
|
call.media.answerSDP(session.localMediaIP()),
|
||||||
|
session.dialogContactHeader(),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
@@ -231,6 +274,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
|||||||
return vowifi.Call{}, err
|
return vowifi.Call{}, err
|
||||||
}
|
}
|
||||||
session.setCallState(id, "active")
|
session.setCallState(id, "active")
|
||||||
|
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||||
if call.media.ready() {
|
if call.media.ready() {
|
||||||
session.setCallMediaReady(id)
|
session.setCallMediaReady(id)
|
||||||
}
|
}
|
||||||
@@ -264,7 +308,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
method := "BYE"
|
method := "BYE"
|
||||||
if direction == "outgoing" && (state == "dialing" || state == "ringing") {
|
if direction == "outgoing" && (state == "dialing" || state == "ringing" || state == "early_media") {
|
||||||
method = "CANCEL"
|
method = "CANCEL"
|
||||||
}
|
}
|
||||||
err := session.sendDialogRequest(ctx, call, method)
|
err := session.sendDialogRequest(ctx, call, method)
|
||||||
@@ -288,6 +332,12 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
if callID == "" {
|
if callID == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
session.callMu.Lock()
|
||||||
|
existing := session.calls[callID]
|
||||||
|
session.callMu.Unlock()
|
||||||
|
if existing != nil && existing.public.State == "active" {
|
||||||
|
return session.handleDialogOffer(request, respond, existing)
|
||||||
|
}
|
||||||
number := identityNumber(request.value("From"))
|
number := identityNumber(request.value("From"))
|
||||||
target := headerURI(request.value("Contact"))
|
target := headerURI(request.value("Contact"))
|
||||||
if target == "" {
|
if target == "" {
|
||||||
@@ -311,8 +361,9 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
}
|
}
|
||||||
call := &imsCall{
|
call := &imsCall{
|
||||||
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
||||||
callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag,
|
callID: callID, target: target, inviteTarget: request.URI, from: request.value("To") + ";tag=" + session.fromTag,
|
||||||
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media,
|
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media,
|
||||||
|
pracked: make(map[string]bool),
|
||||||
}
|
}
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
@@ -322,6 +373,21 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
_ = respond(response)
|
_ = respond(response)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case "PRACK":
|
||||||
|
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil)
|
||||||
|
if err == nil {
|
||||||
|
_ = respond(response)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "UPDATE":
|
||||||
|
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||||
|
session.callMu.Lock()
|
||||||
|
call := session.calls[callID]
|
||||||
|
session.callMu.Unlock()
|
||||||
|
if call == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return session.handleDialogOffer(request, respond, call)
|
||||||
case "ACK":
|
case "ACK":
|
||||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
@@ -366,9 +432,199 @@ func (session *Session) sendACK(call *imsCall) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendRejectedInviteACK acknowledges a non-2xx final response using the
|
||||||
|
// original INVITE transaction branch and request URI. Unlike a 2xx ACK this is
|
||||||
|
// part of the INVITE transaction; sending a dialog-style ACK with a new branch
|
||||||
|
// leaves the P-CSCF retransmitting the rejection and leaking transaction state.
|
||||||
|
func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipResponse) error {
|
||||||
|
if call == nil || response == nil || response.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if session == nil || session.conn == nil {
|
||||||
|
return errors.New("ims: SIP connection unavailable for rejected INVITE ACK")
|
||||||
|
}
|
||||||
|
target := call.inviteTarget
|
||||||
|
if target == "" {
|
||||||
|
target = call.target
|
||||||
|
}
|
||||||
|
to := strings.TrimSpace(response.value("To"))
|
||||||
|
if to == "" {
|
||||||
|
to = call.to
|
||||||
|
}
|
||||||
|
lines := []string{
|
||||||
|
"ACK " + target + " SIP/2.0",
|
||||||
|
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), call.branch),
|
||||||
|
"Max-Forwards: 70",
|
||||||
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
session.mu.Unlock()
|
||||||
|
lines = append(lines, securityHeaders...)
|
||||||
|
for _, route := range call.routes {
|
||||||
|
lines = append(lines, "Route: "+route)
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
"From: "+call.from,
|
||||||
|
"To: "+to,
|
||||||
|
"Call-ID: "+call.callID,
|
||||||
|
fmt.Sprintf("CSeq: %d ACK", call.cseq),
|
||||||
|
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
|
"Content-Length: 0", "", "",
|
||||||
|
)
|
||||||
|
session.writeMu.Lock()
|
||||||
|
_, err := session.conn.Write([]byte(strings.Join(lines, "\r\n")))
|
||||||
|
session.writeMu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func reliableProvisional(response *sipResponse) bool {
|
||||||
|
if response == nil || strings.TrimSpace(response.value("RSeq")) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, token := range strings.Split(strings.ToLower(response.value("Require")), ",") {
|
||||||
|
if strings.TrimSpace(token) == "100rel" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) updateCallDialogFromResponse(call *imsCall, response *sipResponse) {
|
||||||
|
if call == nil || response == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.callMu.Lock()
|
||||||
|
defer session.callMu.Unlock()
|
||||||
|
call.to = response.value("To")
|
||||||
|
call.remoteTag = headerParameter(call.to, "tag")
|
||||||
|
if contact := headerURI(response.value("Contact")); contact != "" {
|
||||||
|
call.target = contact
|
||||||
|
}
|
||||||
|
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
||||||
|
call.routes = reverseStrings(recordRoutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
|
||||||
|
rseq := strings.TrimSpace(response.value("RSeq"))
|
||||||
|
inviteCSeq := strings.TrimSpace(response.value("CSeq"))
|
||||||
|
if rseq == "" || inviteCSeq == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := rseq + "|" + inviteCSeq
|
||||||
|
session.callMu.Lock()
|
||||||
|
if call.pracked == nil {
|
||||||
|
call.pracked = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if call.pracked[key] || call.public.EndedAt != nil {
|
||||||
|
session.callMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call.pracked[key] = true
|
||||||
|
target, to, from := call.target, call.to, call.from
|
||||||
|
routes := append([]string(nil), call.routes...)
|
||||||
|
session.callMu.Unlock()
|
||||||
|
|
||||||
|
session.mu.Lock()
|
||||||
|
cseq := session.cseq
|
||||||
|
session.cseq++
|
||||||
|
session.mu.Unlock()
|
||||||
|
branch, _ := randomHex(12)
|
||||||
|
lines := []string{
|
||||||
|
"PRACK " + target + " SIP/2.0",
|
||||||
|
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||||
|
"Max-Forwards: 70",
|
||||||
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
session.mu.Unlock()
|
||||||
|
lines = append(lines, securityHeaders...)
|
||||||
|
for _, route := range routes {
|
||||||
|
lines = append(lines, "Route: "+route)
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
|
||||||
|
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
|
||||||
|
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
|
"Content-Length: 0", "", "",
|
||||||
|
)
|
||||||
|
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
result, err := session.exchangeRuntime(ctx, []byte(strings.Join(lines, "\r\n")), sipTransactionKey{callID: call.callID, cseq: cseq, method: "PRACK"})
|
||||||
|
if err != nil || result.StatusCode < 200 || result.StatusCode >= 300 {
|
||||||
|
reason := "reliable provisional response could not be acknowledged"
|
||||||
|
if err != nil {
|
||||||
|
reason = err.Error()
|
||||||
|
}
|
||||||
|
session.finishCall(call.callID, "failed", 0, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) handleDialogOffer(request *sipRequest, respond func([]byte) error, call *imsCall) bool {
|
||||||
|
var body []byte
|
||||||
|
if len(request.Body) > 0 {
|
||||||
|
if err := call.media.configureRemote(request.Body); err != nil {
|
||||||
|
if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil {
|
||||||
|
_ = respond(response)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
body = call.media.answerSDP(session.localMediaIP())
|
||||||
|
session.setCallMediaReady(call.callID)
|
||||||
|
}
|
||||||
|
extraHeaders := []string(nil)
|
||||||
|
if request.Method == "INVITE" {
|
||||||
|
extraHeaders = append(extraHeaders, session.dialogContactHeader())
|
||||||
|
}
|
||||||
|
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, body, extraHeaders...)
|
||||||
|
if err == nil {
|
||||||
|
_ = respond(response)
|
||||||
|
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) startSessionTimer(call *imsCall, header string) {
|
||||||
|
value := strings.TrimSpace(strings.Split(header, ";")[0])
|
||||||
|
seconds, err := strconv.Atoi(value)
|
||||||
|
if err != nil || seconds < 90 || seconds > 86400 || call == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(session.refreshContext)
|
||||||
|
session.callMu.Lock()
|
||||||
|
if call.sessionCancel != nil {
|
||||||
|
call.sessionCancel()
|
||||||
|
}
|
||||||
|
call.sessionExpires = seconds
|
||||||
|
call.sessionCancel = cancel
|
||||||
|
session.callMu.Unlock()
|
||||||
|
go func() {
|
||||||
|
interval := time.Duration(seconds) * time.Second / 2
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
refreshContext, cancelRefresh := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
refreshErr := session.sendDialogRequest(refreshContext, call, "UPDATE")
|
||||||
|
cancelRefresh()
|
||||||
|
if refreshErr != nil {
|
||||||
|
session.finishCall(call.callID, "failed", 0, "SIP session refresh failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.Reset(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error {
|
func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error {
|
||||||
cseq := call.cseq
|
cseq := call.cseq
|
||||||
if method == "BYE" {
|
if method == "BYE" || method == "UPDATE" {
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
cseq = session.cseq
|
cseq = session.cseq
|
||||||
session.cseq++
|
session.cseq++
|
||||||
@@ -396,15 +652,23 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
|||||||
if method == "CANCEL" {
|
if method == "CANCEL" {
|
||||||
branch = call.branch
|
branch = call.branch
|
||||||
}
|
}
|
||||||
|
target := call.target
|
||||||
|
if method == "CANCEL" && call.inviteTarget != "" {
|
||||||
|
target = call.inviteTarget
|
||||||
|
}
|
||||||
to := call.to
|
to := call.to
|
||||||
if to == "" {
|
if to == "" {
|
||||||
to = "<" + call.target + ">"
|
to = "<" + call.target + ">"
|
||||||
}
|
}
|
||||||
lines := []string{
|
lines := []string{
|
||||||
method + " " + call.target + " SIP/2.0",
|
method + " " + target + " SIP/2.0",
|
||||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||||
"Max-Forwards: 70",
|
"Max-Forwards: 70",
|
||||||
}
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||||
|
session.mu.Unlock()
|
||||||
|
lines = append(lines, securityHeaders...)
|
||||||
for _, route := range call.routes {
|
for _, route := range call.routes {
|
||||||
lines = append(lines, "Route: "+route)
|
lines = append(lines, "Route: "+route)
|
||||||
}
|
}
|
||||||
@@ -413,8 +677,19 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
|||||||
"To: "+to,
|
"To: "+to,
|
||||||
"Call-ID: "+call.callID,
|
"Call-ID: "+call.callID,
|
||||||
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
||||||
"Content-Length: 0", "", "",
|
"Supported: 100rel, timer",
|
||||||
|
"User-Agent: "+session.callUserAgent(),
|
||||||
)
|
)
|
||||||
|
if method != "CANCEL" {
|
||||||
|
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
|
||||||
|
}
|
||||||
|
if method == "UPDATE" {
|
||||||
|
lines = append(lines, session.dialogContactHeader())
|
||||||
|
}
|
||||||
|
lines = append(lines, "Content-Length: 0", "", "")
|
||||||
|
if method == "UPDATE" && call.sessionExpires > 0 {
|
||||||
|
lines = append(lines[:len(lines)-3], fmt.Sprintf("Session-Expires: %d;refresher=uac", call.sessionExpires), "Content-Length: 0", "", "")
|
||||||
|
}
|
||||||
return []byte(strings.Join(lines, "\r\n"))
|
return []byte(strings.Join(lines, "\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +701,7 @@ func (session *Session) localMediaIP() net.IP {
|
|||||||
return addressIP(localAddress)
|
return addressIP(localAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) {
|
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte, extraHeaders ...string) ([]byte, error) {
|
||||||
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"}
|
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"}
|
||||||
reason := reasons[status]
|
reason := reasons[status]
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
@@ -446,6 +721,14 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
|||||||
lines = append(lines, "Via: "+value)
|
lines = append(lines, "Via: "+value)
|
||||||
}
|
}
|
||||||
lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq)
|
lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq)
|
||||||
|
for _, header := range extraHeaders {
|
||||||
|
if strings.TrimSpace(header) != "" {
|
||||||
|
lines = append(lines, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(request.value("Session-Expires")); value != "" && status >= 200 && status < 300 && (request.Method == "INVITE" || request.Method == "UPDATE") {
|
||||||
|
lines = append(lines, "Supported: timer", "Session-Expires: "+value)
|
||||||
|
}
|
||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
lines = append(lines, "Content-Type: application/sdp")
|
lines = append(lines, "Content-Type: application/sdp")
|
||||||
}
|
}
|
||||||
@@ -453,10 +736,110 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
|||||||
return append([]byte(strings.Join(lines, "\r\n")), body...), nil
|
return append([]byte(strings.Join(lines, "\r\n")), body...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (session *Session) dialogContactHeader() string {
|
||||||
|
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
|
||||||
|
if strings.TrimSpace(session.instanceID) != "" {
|
||||||
|
contact += `;+sip.instance="<` + session.instanceID + `>"`
|
||||||
|
}
|
||||||
|
return contact + `;audio;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTargetURI(number, domain string, profile vowifi.CarrierProfile) string {
|
||||||
|
domain = strings.TrimSpace(domain)
|
||||||
|
if profile.IMSDialURIScheme == "sip" {
|
||||||
|
target := "sip:" + number + "@" + domain
|
||||||
|
if profile.IMSUserEqPhone {
|
||||||
|
target += ";user=phone"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(number, "+") {
|
||||||
|
return "tel:" + number
|
||||||
|
}
|
||||||
|
return "tel:" + number + ";phone-context=" + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// callOriginatingIdentitiesLocked selects only a number that IMS explicitly
|
||||||
|
// associated with this registration. 3GPP originating sessions use that
|
||||||
|
// public identity in both From and P-Preferred-Identity; some TAS deployments
|
||||||
|
// accept an IMSI IMPU at the P-CSCF and then terminate the session immediately.
|
||||||
|
// The fallback deliberately remains the registered IMPU and never derives a
|
||||||
|
// telephone number from IMSI digits.
|
||||||
|
func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierProfile) (from, preferred, source string) {
|
||||||
|
if number, numberSource, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok {
|
||||||
|
from = "sip:" + number + "@" + session.identity.domain
|
||||||
|
if profile.IMSUserEqPhone {
|
||||||
|
from += ";user=phone"
|
||||||
|
}
|
||||||
|
return from, "tel:" + number, numberSource
|
||||||
|
}
|
||||||
|
return session.identity.public, session.identity.public, "registered_impu"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) pAccessNetworkInfo() string {
|
||||||
|
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||||
|
node := strings.TrimSpace(profile.PANINode)
|
||||||
|
if node == "" {
|
||||||
|
node = "000000000000"
|
||||||
|
}
|
||||||
|
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||||
|
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||||
|
value += ";country=" + country
|
||||||
|
}
|
||||||
|
return value + ";network-provided"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) callUserAgent() string {
|
||||||
|
if session != nil && session.provider != nil {
|
||||||
|
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "vocat/1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func callResponseDiagnostic(response *sipResponse) string {
|
||||||
|
if response == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
if reason := safeSIPDiagnostic(response.Reason); reason != "" {
|
||||||
|
parts = append(parts, reason)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Reason", "Warning"} {
|
||||||
|
for _, value := range response.values(name) {
|
||||||
|
if value = safeSIPDiagnostic(value); value != "" {
|
||||||
|
parts = append(parts, name+": "+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safeSIPDiagnostic(strings.Join(parts, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) logCallResponse(response *sipResponse, diagnostic string) {
|
||||||
|
if session == nil || session.provider == nil || session.provider.config.Logger == nil || response == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.provider.config.Logger.Info("IMS call response",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||||
|
"sip_status", response.StatusCode,
|
||||||
|
"diagnostic", diagnostic,
|
||||||
|
"content_type", safeSIPDiagnostic(response.value("Content-Type")),
|
||||||
|
"body_bytes", len(response.Body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) setCallState(id, state string) {
|
func (session *Session) setCallState(id, state string) {
|
||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
if call := session.calls[id]; call != nil {
|
if call := session.calls[id]; call != nil {
|
||||||
call.public.State = state
|
call.public.State = state
|
||||||
|
if state == "active" && call.public.AnsweredAt == nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
call.public.AnsweredAt = &now
|
||||||
|
}
|
||||||
if state != "ended" && state != "failed" {
|
if state != "ended" && state != "failed" {
|
||||||
call.public.EndedAt = nil
|
call.public.EndedAt = nil
|
||||||
}
|
}
|
||||||
@@ -509,6 +892,10 @@ func (session *Session) finishCall(id, state string, code int, reason string) {
|
|||||||
call.public.Reason = reason
|
call.public.Reason = reason
|
||||||
}
|
}
|
||||||
call.public.EndedAt = &now
|
call.public.EndedAt = &now
|
||||||
|
if call.sessionCancel != nil {
|
||||||
|
call.sessionCancel()
|
||||||
|
call.sessionCancel = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
if media != nil {
|
if media != nil {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package ims
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,7 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
||||||
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
client, peer := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer peer.Close()
|
||||||
|
session := &Session{
|
||||||
|
fromTag: "local-tag", calls: make(map[string]*imsCall), conn: client,
|
||||||
|
identity: identitySet{user: "subscriber"}, transport: "udp",
|
||||||
|
}
|
||||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||||
"INVITE sip:[email protected] SIP/2.0",
|
"INVITE sip:[email protected] SIP/2.0",
|
||||||
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming",
|
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming",
|
||||||
@@ -42,6 +50,9 @@ func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
|||||||
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") {
|
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") {
|
||||||
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
|
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(string(responses[1]), "Contact: <sip:subscriber@pipe;transport=udp>") {
|
||||||
|
t.Fatalf("answer omitted dialog Contact: %q", responses[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIncomingCallCanBeRejected(t *testing.T) {
|
func TestIncomingCallCanBeRejected(t *testing.T) {
|
||||||
@@ -116,3 +127,155 @@ func TestValidCallNumber(t *testing.T) {
|
|||||||
t.Fatal("call number validation mismatch")
|
t.Fatal("call number validation mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
||||||
|
client, peer := net.Pipe()
|
||||||
|
defer peer.Close()
|
||||||
|
refreshContext, cancelRefresh := context.WithCancel(context.Background())
|
||||||
|
defer cancelRefresh()
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{
|
||||||
|
UserAgent: "VoCat Test",
|
||||||
|
SecurityMode: SecurityDisabled,
|
||||||
|
}},
|
||||||
|
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
|
||||||
|
HomeMCC: "234", HomeMNC: "33", IMSI: "234330000000001", ICCID: "8944300000000000000",
|
||||||
|
}},
|
||||||
|
identity: identitySet{
|
||||||
|
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||||
|
public: "sip:[email protected]",
|
||||||
|
user: "234330000000001",
|
||||||
|
},
|
||||||
|
endpoint: pcscfEndpoint{host: "pcscf.test", port: 5060},
|
||||||
|
transport: "tcp",
|
||||||
|
conn: client,
|
||||||
|
fromTag: "local-tag",
|
||||||
|
instanceID: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||||
|
cseq: 1,
|
||||||
|
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||||
|
calls: make(map[string]*imsCall),
|
||||||
|
refreshContext: refreshContext,
|
||||||
|
evidence: vowifi.IMSEvidence{
|
||||||
|
PAssociatedURI: []string{"<tel:+447700900123>"},
|
||||||
|
ServiceRoute: []string{"<sip:route.ims.test;lr>"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wireResult := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
packet, _ := io.ReadAll(peer)
|
||||||
|
wireResult <- string(packet)
|
||||||
|
}()
|
||||||
|
|
||||||
|
call, err := session.DialCall(context.Background(), "888")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session.finishCall(call.ID, "ended", 0, "test complete")
|
||||||
|
cancelRefresh()
|
||||||
|
_ = client.Close()
|
||||||
|
wire := <-wireResult
|
||||||
|
|
||||||
|
for _, expected := range []string{
|
||||||
|
"INVITE sip:[email protected] SIP/2.0\r\n",
|
||||||
|
"To: <sip:[email protected]>\r\n",
|
||||||
|
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
||||||
|
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||||
|
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||||
|
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||||
|
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;country=GB;network-provided\r\n",
|
||||||
|
"User-Agent: VoCat Test\r\n",
|
||||||
|
"Accept: application/sdp\r\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(wire, expected) {
|
||||||
|
t.Fatalf("INVITE omitted %q:\n%s", expected, wire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
|
||||||
|
session := &Session{
|
||||||
|
identity: identitySet{
|
||||||
|
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||||
|
public: "sip:[email protected]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
from, preferred, source := session.callOriginatingIdentitiesLocked(vowifi.CarrierProfile{})
|
||||||
|
if from != session.identity.public || preferred != session.identity.public || source != "registered_impu" {
|
||||||
|
t.Fatalf("fallback identities = (%q, %q, %q)", from, preferred, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallTargetURIUsesPhoneContextOnlyForLocalNumbers(t *testing.T) {
|
||||||
|
domain := "ims.mnc033.mcc234.3gppnetwork.org"
|
||||||
|
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:888;phone-context="+domain {
|
||||||
|
t.Fatalf("local target = %q", got)
|
||||||
|
}
|
||||||
|
if got := callTargetURI("+447700900123", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:+447700900123" {
|
||||||
|
t.Fatalf("global target = %q", got)
|
||||||
|
}
|
||||||
|
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip"}); got != "sip:888@"+domain {
|
||||||
|
t.Fatalf("SIP target = %q", got)
|
||||||
|
}
|
||||||
|
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip", IMSUserEqPhone: true}); got != "sip:888@"+domain+";user=phone" {
|
||||||
|
t.Fatalf("SIP user=phone target = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallResponseDiagnosticIncludesNetworkReason(t *testing.T) {
|
||||||
|
response := &sipResponse{
|
||||||
|
StatusCode: 487,
|
||||||
|
Reason: "Request Terminated",
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"reason": {`Q.850;cause=31;text="Normal, unspecified"`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
want := `Request Terminated; Reason: Q.850;cause=31;text="Normal, unspecified"`
|
||||||
|
if got := callResponseDiagnostic(response); got != want {
|
||||||
|
t.Fatalf("diagnostic = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedInviteACKUsesOriginalTransaction(t *testing.T) {
|
||||||
|
client, peer := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer peer.Close()
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{UserAgent: "VoCat Test"}},
|
||||||
|
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "33"}},
|
||||||
|
identity: identitySet{domain: "ims.mnc033.mcc234.3gppnetwork.org"},
|
||||||
|
transport: "tcp",
|
||||||
|
conn: client,
|
||||||
|
}
|
||||||
|
call := &imsCall{
|
||||||
|
callID: "call-1",
|
||||||
|
inviteTarget: "sip:[email protected]",
|
||||||
|
from: "<sip:[email protected]>;tag=local",
|
||||||
|
to: "<sip:[email protected]>",
|
||||||
|
branch: "original-branch",
|
||||||
|
cseq: 41,
|
||||||
|
routes: []string{"<sip:pcscf.test;lr>"},
|
||||||
|
}
|
||||||
|
response := &sipResponse{StatusCode: 487, Headers: map[string][]string{
|
||||||
|
"to": {"<sip:[email protected]>;tag=tas"},
|
||||||
|
}}
|
||||||
|
ackResult := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
buffer := make([]byte, 4096)
|
||||||
|
count, _ := peer.Read(buffer)
|
||||||
|
ackResult <- string(buffer[:count])
|
||||||
|
}()
|
||||||
|
if err := session.sendRejectedInviteACK(call, response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ack := <-ackResult
|
||||||
|
for _, expected := range []string{
|
||||||
|
"ACK sip:[email protected] SIP/2.0\r\n",
|
||||||
|
"branch=z9hG4bKoriginal-branch;rport",
|
||||||
|
"To: <sip:[email protected]>;tag=tas\r\n",
|
||||||
|
"CSeq: 41 ACK\r\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(ack, expected) {
|
||||||
|
t.Fatalf("rejected INVITE ACK omitted %q:\n%s", expected, ack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+178
-54
@@ -36,20 +36,24 @@ var (
|
|||||||
// LocalAddress is empty, Provider uses the corresponding value proven by the
|
// LocalAddress is empty, Provider uses the corresponding value proven by the
|
||||||
// TunnelSession. The default transport is TCP and the default port is 5060.
|
// TunnelSession. The default transport is TCP and the default port is 5060.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
PCSCF string
|
PCSCF string
|
||||||
LocalAddress string
|
LocalAddress string
|
||||||
Transport string
|
Transport string
|
||||||
TransportByPLMN map[string]string
|
TransportByPLMN map[string]string
|
||||||
Port int
|
// AutoTransportFallback tries the alternate TCP/UDP transport only when
|
||||||
RegistrationExpiry time.Duration
|
// the initial P-CSCF attempt produced no SIP response at all. A challenge
|
||||||
TransactionTimeout time.Duration
|
// or rejection is authoritative and is never retried as another transport.
|
||||||
PrivateIdentity string
|
AutoTransportFallback bool
|
||||||
PublicIdentity string
|
Port int
|
||||||
UserAgent string
|
RegistrationExpiry time.Duration
|
||||||
SecurityMode SecurityMode
|
TransactionTimeout time.Duration
|
||||||
IPSecInstaller IPSecSAInstaller
|
PrivateIdentity string
|
||||||
ProtectedClientPort int
|
PublicIdentity string
|
||||||
ProtectedServerPort int
|
UserAgent string
|
||||||
|
SecurityMode SecurityMode
|
||||||
|
IPSecInstaller IPSecSAInstaller
|
||||||
|
ProtectedClientPort int
|
||||||
|
ProtectedServerPort int
|
||||||
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
|
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
|
||||||
// and AT+CSCA empty. It must be an international or national digit string.
|
// and AT+CSCA empty. It must be an international or national digit string.
|
||||||
SMSCenter string
|
SMSCenter string
|
||||||
@@ -71,9 +75,11 @@ type Config struct {
|
|||||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||||
// runtime dependency outside the Go standard library.
|
// runtime dependency outside the Go standard library.
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
aka vowifi.AKAProvider
|
aka vowifi.AKAProvider
|
||||||
config Config
|
config Config
|
||||||
installer IPSecSAInstaller
|
installer IPSecSAInstaller
|
||||||
|
transportMu sync.RWMutex
|
||||||
|
transportCache map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||||
@@ -88,7 +94,10 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
|||||||
if installer == nil {
|
if installer == nil {
|
||||||
installer = defaultIPSecInstaller()
|
installer = defaultIPSecInstaller()
|
||||||
}
|
}
|
||||||
return &Provider{aka: aka, config: normalized, installer: installer}, nil
|
return &Provider{
|
||||||
|
aka: aka, config: normalized, installer: installer,
|
||||||
|
transportCache: make(map[string]string),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeConfig(config Config) (Config, error) {
|
func normalizeConfig(config Config) (Config, error) {
|
||||||
@@ -224,10 +233,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
||||||
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||||
}
|
}
|
||||||
transport := transportForIdentity(provider.config, request.Identity)
|
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
|
||||||
if transport == "" {
|
if cached := provider.cachedTransport(request.Identity); cached != "" {
|
||||||
|
transport = cached
|
||||||
|
carrierSelected = true
|
||||||
|
}
|
||||||
|
if transport == "" && !carrierSelected {
|
||||||
transport = transportHint
|
transport = transportHint
|
||||||
}
|
}
|
||||||
|
if transport == "" {
|
||||||
|
transport = provider.config.Transport
|
||||||
|
}
|
||||||
if transport == "" {
|
if transport == "" {
|
||||||
transport = "tcp"
|
transport = "tcp"
|
||||||
}
|
}
|
||||||
@@ -250,31 +266,96 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
||||||
}
|
}
|
||||||
|
|
||||||
connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address())
|
transports := []string{transport}
|
||||||
if err != nil {
|
if provider.config.AutoTransportFallback {
|
||||||
return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err)
|
alternate := "udp"
|
||||||
|
if transport == "udp" {
|
||||||
|
alternate = "tcp"
|
||||||
|
}
|
||||||
|
transports = append(transports, alternate)
|
||||||
}
|
}
|
||||||
session, err := newSession(provider, request, identities, endpoint, transport, connection)
|
var lastErr error
|
||||||
if err != nil {
|
for attempt, candidate := range transports {
|
||||||
_ = connection.Close()
|
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||||
return nil, err
|
if dialErr != nil {
|
||||||
}
|
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
||||||
if err := session.establish(ctx); err != nil {
|
if attempt+1 < len(transports) && ctx.Err() == nil {
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||||
|
if sessionErr != nil {
|
||||||
|
_ = connection.Close()
|
||||||
|
return nil, sessionErr
|
||||||
|
}
|
||||||
|
establishErr := session.establish(ctx)
|
||||||
|
if establishErr == nil {
|
||||||
|
provider.rememberTransport(request.Identity, candidate)
|
||||||
|
if attempt > 0 {
|
||||||
|
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
||||||
|
"transport", candidate)
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
sipResponseObserved := session.evidence.LastSIPCode != 0
|
||||||
session.abort()
|
session.abort()
|
||||||
return nil, err
|
lastErr = establishErr
|
||||||
|
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||||
}
|
}
|
||||||
return session, nil
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
if transport, selected := carrierTransportForIdentity(config, identity); selected {
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
|
||||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
|
||||||
return transport
|
return transport
|
||||||
}
|
}
|
||||||
return config.Transport
|
return config.Transport
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func carrierTransportForIdentity(config Config, identity vowifi.SIMIdentity) (string, bool) {
|
||||||
|
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||||
|
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||||
|
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||||
|
return transport, true
|
||||||
|
}
|
||||||
|
if transport := vowifi.ResolveCarrierProfile(identity).IMSTransport; transport != "" {
|
||||||
|
return transport, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func transportCacheKey(identity vowifi.SIMIdentity) string {
|
||||||
|
if iccid := strings.TrimSpace(identity.ICCID); iccid != "" {
|
||||||
|
return "iccid:" + iccid
|
||||||
|
}
|
||||||
|
return "plmn:" + strings.TrimSpace(identity.HomeMCC) + "/" + strings.TrimSpace(identity.HomeMNC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) cachedTransport(identity vowifi.SIMIdentity) string {
|
||||||
|
provider.transportMu.RLock()
|
||||||
|
transport := provider.transportCache[transportCacheKey(identity)]
|
||||||
|
provider.transportMu.RUnlock()
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) rememberTransport(identity vowifi.SIMIdentity, transport string) {
|
||||||
|
provider.transportMu.Lock()
|
||||||
|
provider.transportCache[transportCacheKey(identity)] = transport
|
||||||
|
provider.transportMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) logTransportFallback(identity vowifi.SIMIdentity, from, to string, err error) {
|
||||||
|
provider.config.Logger.Warn("IMS P-CSCF did not respond; trying alternate SIP transport",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(identity).ID,
|
||||||
|
"from_transport", from, "to_transport", to, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
type identitySet struct {
|
type identitySet struct {
|
||||||
domain string
|
domain string
|
||||||
private string
|
private string
|
||||||
@@ -298,7 +379,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
|||||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||||
privateDomain := domain
|
privateDomain := domain
|
||||||
publicDomain := domain
|
publicDomain := domain
|
||||||
if vowifi.IsATT310280(identity) {
|
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
|
||||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||||
// the generic 3GPP PLMN IMS domain.
|
// the generic 3GPP PLMN IMS domain.
|
||||||
domain = "one.att.net"
|
domain = "one.att.net"
|
||||||
@@ -620,18 +701,11 @@ func newSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||||
if usesO2GermanyIMSProfile(identity) {
|
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
||||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
|
||||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
|
||||||
return "null"
|
|
||||||
}
|
|
||||||
return "aes-cbc"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
|
||||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
|
||||||
return mcc+mnc == "2623"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) abort() {
|
func (session *Session) abort() {
|
||||||
@@ -872,7 +946,7 @@ func (session *Session) buildRegister(
|
|||||||
}
|
}
|
||||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||||
supported := "path, gruu"
|
supported := "path, gruu"
|
||||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, SUBSCRIBE, NOTIFY"
|
||||||
if o2Germany {
|
if o2Germany {
|
||||||
// Match the complete IMS capability set used by the previously working
|
// Match the complete IMS capability set used by the previously working
|
||||||
// VoHive client. O2 validates more of the initial UE security profile
|
// VoHive client. O2 validates more of the initial UE security profile
|
||||||
@@ -913,6 +987,12 @@ func (session *Session) buildRegister(
|
|||||||
"Accept-Contact: *;+g.3gpp.smsip",
|
"Accept-Contact: *;+g.3gpp.smsip",
|
||||||
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
lines = append(lines,
|
||||||
|
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||||
|
"Accept-Contact: *;+g.3gpp.smsip",
|
||||||
|
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if session.securityOffered() {
|
if session.securityOffered() {
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
@@ -951,19 +1031,33 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
method: "REGISTER",
|
method: "REGISTER",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
transactionDeadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(transactionDeadline) {
|
||||||
deadline = contextDeadline
|
transactionDeadline = contextDeadline
|
||||||
}
|
}
|
||||||
readUDP := session.protectedUDP
|
readUDP := session.protectedUDP
|
||||||
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
|
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
|
||||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
setReadDeadline := func(deadline time.Time) error {
|
||||||
return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||||
}
|
return fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||||
if protectedUDP {
|
|
||||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
|
||||||
return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
|
||||||
}
|
}
|
||||||
|
if protectedUDP {
|
||||||
|
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||||
|
return fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
retransmitInterval := time.Duration(0)
|
||||||
|
readDeadline := transactionDeadline
|
||||||
|
if session.transport == "udp" {
|
||||||
|
retransmitInterval = sipMessageRetransmitT1
|
||||||
|
if candidate := time.Now().Add(retransmitInterval); candidate.Before(readDeadline) {
|
||||||
|
readDeadline = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := setReadDeadline(readDeadline); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
stopCancellation := context.AfterFunc(ctx, func() {
|
stopCancellation := context.AfterFunc(ctx, func() {
|
||||||
_ = session.conn.SetDeadline(time.Now())
|
_ = session.conn.SetDeadline(time.Now())
|
||||||
@@ -976,6 +1070,7 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
|
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retransmissions := 0
|
||||||
for {
|
for {
|
||||||
var response *sipResponse
|
var response *sipResponse
|
||||||
var err error
|
var err error
|
||||||
@@ -1004,6 +1099,31 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
if contextErr := ctx.Err(); contextErr != nil {
|
if contextErr := ctx.Err(); contextErr != nil {
|
||||||
return nil, contextErr
|
return nil, contextErr
|
||||||
}
|
}
|
||||||
|
var networkErr net.Error
|
||||||
|
if retransmitInterval > 0 && errors.As(err, &networkErr) && networkErr.Timeout() &&
|
||||||
|
time.Now().Before(transactionDeadline) {
|
||||||
|
retransmitInterval *= 2
|
||||||
|
if retransmitInterval > sipMessageRetransmitMax {
|
||||||
|
retransmitInterval = sipMessageRetransmitMax
|
||||||
|
}
|
||||||
|
nextDeadline := time.Now().Add(retransmitInterval)
|
||||||
|
if nextDeadline.After(transactionDeadline) {
|
||||||
|
nextDeadline = transactionDeadline
|
||||||
|
}
|
||||||
|
// The previous read deadline has already expired and net.Conn applies
|
||||||
|
// it to writes too. Extend it before retransmitting.
|
||||||
|
if deadlineErr := setReadDeadline(nextDeadline); deadlineErr != nil {
|
||||||
|
return nil, deadlineErr
|
||||||
|
}
|
||||||
|
if _, writeErr := session.conn.Write(request); writeErr != nil {
|
||||||
|
return nil, fmt.Errorf("ims: retransmit SIP REGISTER: %w", writeErr)
|
||||||
|
}
|
||||||
|
retransmissions++
|
||||||
|
session.provider.config.Logger.Debug("IMS SIP REGISTER retransmitted",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||||
|
"transport", session.transport, "attempt", retransmissions)
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
|
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
|
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
|
||||||
@@ -1014,6 +1134,10 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
||||||
|
retransmitInterval = 0
|
||||||
|
if err := setReadDeadline(transactionDeadline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +20,40 @@ type evidenceTunnel struct {
|
|||||||
evidence vowifi.TunnelEvidence
|
evidence vowifi.TunnelEvidence
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type immediateTimeoutError struct{}
|
||||||
|
|
||||||
|
func (immediateTimeoutError) Error() string { return "test timeout" }
|
||||||
|
func (immediateTimeoutError) Timeout() bool { return true }
|
||||||
|
func (immediateTimeoutError) Temporary() bool { return true }
|
||||||
|
|
||||||
|
type registerRetransmitConn struct {
|
||||||
|
writes int
|
||||||
|
response []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *registerRetransmitConn) Read(destination []byte) (int, error) {
|
||||||
|
if connection.writes < 2 {
|
||||||
|
return 0, immediateTimeoutError{}
|
||||||
|
}
|
||||||
|
return copy(destination, connection.response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *registerRetransmitConn) Write(source []byte) (int, error) {
|
||||||
|
connection.writes++
|
||||||
|
return len(source), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*registerRetransmitConn) Close() error { return nil }
|
||||||
|
func (*registerRetransmitConn) LocalAddr() net.Addr {
|
||||||
|
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
|
||||||
|
}
|
||||||
|
func (*registerRetransmitConn) RemoteAddr() net.Addr {
|
||||||
|
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
|
||||||
|
}
|
||||||
|
func (*registerRetransmitConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (*registerRetransmitConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (*registerRetransmitConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
|
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
|
||||||
return tunnel.evidence
|
return tunnel.evidence
|
||||||
}
|
}
|
||||||
@@ -73,6 +109,62 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||||
|
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
||||||
|
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
||||||
|
}
|
||||||
|
if got := transportForIdentity(Config{
|
||||||
|
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
||||||
|
}, identity); got != "tcp" {
|
||||||
|
t.Fatalf("explicit configuration did not override profile: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderCachesSuccessfulTransportPerSIM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
provider := &Provider{transportCache: make(map[string]string)}
|
||||||
|
first := vowifi.SIMIdentity{ICCID: "8901000000000000001", HomeMCC: "001", HomeMNC: "01"}
|
||||||
|
second := vowifi.SIMIdentity{ICCID: "8901000000000000002", HomeMCC: "001", HomeMNC: "01"}
|
||||||
|
provider.rememberTransport(first, "udp")
|
||||||
|
if got := provider.cachedTransport(first); got != "udp" {
|
||||||
|
t.Fatalf("cached first transport = %q", got)
|
||||||
|
}
|
||||||
|
if got := provider.cachedTransport(second); got != "" {
|
||||||
|
t.Fatalf("second SIM inherited cached transport %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPRegisterRetransmitsBeforeTransactionTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
connection := ®isterRetransmitConn{response: []byte(strings.Join([]string{
|
||||||
|
"SIP/2.0 200 OK",
|
||||||
|
"Call-ID: register-retransmit-test",
|
||||||
|
"CSeq: 7 REGISTER",
|
||||||
|
"Content-Length: 0",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
}, "\r\n"))}
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{
|
||||||
|
TransactionTimeout: 3 * time.Second,
|
||||||
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
}},
|
||||||
|
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"}},
|
||||||
|
transport: "udp",
|
||||||
|
conn: connection,
|
||||||
|
callID: "register-retransmit-test",
|
||||||
|
}
|
||||||
|
response, err := session.exchange(context.Background(), []byte("REGISTER test"), 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != 200 || connection.writes != 2 {
|
||||||
|
t.Fatalf("response=%#v writes=%d, want SIP 200 after one retransmission", response, connection.writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
|
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
|
||||||
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
|
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
|
||||||
" 23410 ": " +447802000332 ",
|
" 23410 ": " +447802000332 ",
|
||||||
@@ -283,7 +375,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
return fmt.Errorf("unexpected start line %q", startLine)
|
return fmt.Errorf("unexpected start line %q", startLine)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{
|
for _, forbidden := range []string{
|
||||||
"p-access-network-info",
|
|
||||||
"p-visited-network-id",
|
"p-visited-network-id",
|
||||||
"p-preferred-identity",
|
"p-preferred-identity",
|
||||||
} {
|
} {
|
||||||
@@ -295,6 +386,13 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
|
||||||
|
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||||
|
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||||
|
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
||||||
|
}
|
||||||
if step == 0 {
|
if step == 0 {
|
||||||
if headers["authorization"] != "" {
|
if headers["authorization"] != "" {
|
||||||
return errors.New("initial REGISTER unexpectedly authenticated")
|
return errors.New("initial REGISTER unexpectedly authenticated")
|
||||||
|
|||||||
@@ -534,25 +534,7 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
|||||||
for _, protocol := range flow.protocols {
|
for _, protocol := range flow.protocols {
|
||||||
operations = append(operations, xfrmOperation{
|
operations = append(operations, xfrmOperation{
|
||||||
description: flow.description + " " + protocol + " policy",
|
description: flow.description + " " + protocol + " policy",
|
||||||
arguments: []string{
|
arguments: xfrmPolicyArgs(flow, protocol, false),
|
||||||
flow.family,
|
|
||||||
"xfrm", "policy", "add",
|
|
||||||
"src", flow.sourcePrefix,
|
|
||||||
"dst", flow.destinationPrefix,
|
|
||||||
"proto", protocol,
|
|
||||||
"sport", strconv.Itoa(flow.sourcePort),
|
|
||||||
"dport", strconv.Itoa(flow.destinationPort),
|
|
||||||
"dir", flow.direction,
|
|
||||||
"priority", "100",
|
|
||||||
"tmpl",
|
|
||||||
"src", flow.templateSource.String(),
|
|
||||||
"dst", flow.templateDestination.String(),
|
|
||||||
"proto", "esp",
|
|
||||||
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
|
||||||
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
|
||||||
"mode", "transport",
|
|
||||||
"level", "required",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,16 +550,7 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
|||||||
protocol := flow.protocols[protocolIndex]
|
protocol := flow.protocols[protocolIndex]
|
||||||
operations = append(operations, xfrmOperation{
|
operations = append(operations, xfrmOperation{
|
||||||
description: "delete " + flow.description + " " + protocol + " policy",
|
description: "delete " + flow.description + " " + protocol + " policy",
|
||||||
arguments: []string{
|
arguments: xfrmPolicyArgs(flow, protocol, true),
|
||||||
flow.family,
|
|
||||||
"xfrm", "policy", "delete",
|
|
||||||
"src", flow.sourcePrefix,
|
|
||||||
"dst", flow.destinationPrefix,
|
|
||||||
"proto", protocol,
|
|
||||||
"sport", strconv.Itoa(flow.sourcePort),
|
|
||||||
"dport", strconv.Itoa(flow.destinationPort),
|
|
||||||
"dir", flow.direction,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,6 +579,44 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
|||||||
return operations
|
return operations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func xfrmPolicyArgs(flow xfrmFlow, protocol string, delete bool) []string {
|
||||||
|
args := []string{
|
||||||
|
flow.family,
|
||||||
|
"xfrm", "policy",
|
||||||
|
}
|
||||||
|
if delete {
|
||||||
|
args = append(args, "delete")
|
||||||
|
} else {
|
||||||
|
args = append(args, "add")
|
||||||
|
}
|
||||||
|
args = append(args,
|
||||||
|
"src", flow.sourcePrefix,
|
||||||
|
"dst", flow.destinationPrefix,
|
||||||
|
"proto", protocol,
|
||||||
|
)
|
||||||
|
if flow.sourcePort > 0 {
|
||||||
|
args = append(args, "sport", strconv.Itoa(flow.sourcePort))
|
||||||
|
}
|
||||||
|
if flow.destinationPort > 0 {
|
||||||
|
args = append(args, "dport", strconv.Itoa(flow.destinationPort))
|
||||||
|
}
|
||||||
|
args = append(args, "dir", flow.direction)
|
||||||
|
if delete {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
return append(args,
|
||||||
|
"priority", "100",
|
||||||
|
"tmpl",
|
||||||
|
"src", flow.templateSource.String(),
|
||||||
|
"dst", flow.templateDestination.String(),
|
||||||
|
"proto", "esp",
|
||||||
|
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
||||||
|
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
||||||
|
"mode", "transport",
|
||||||
|
"level", "required",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type xfrmFlow struct {
|
type xfrmFlow struct {
|
||||||
description string
|
description string
|
||||||
family string
|
family string
|
||||||
@@ -650,7 +661,7 @@ func xfrmFlows(config IPSecSAConfig) []xfrmFlow {
|
|||||||
{
|
{
|
||||||
description: "P-CSCF-client to UE-server", family: family,
|
description: "P-CSCF-client to UE-server", family: family,
|
||||||
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
||||||
sourcePort: config.PCSCFClientPort, destinationPort: config.UEServerPort,
|
sourcePort: 0, destinationPort: config.UEServerPort,
|
||||||
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
||||||
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
||||||
protocols: []string{"tcp", "udp"},
|
protocols: []string{"tcp", "udp"},
|
||||||
@@ -772,7 +783,7 @@ func zeroBytes(value []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) securityOffered() bool {
|
func (session *Session) securityOffered() bool {
|
||||||
return session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
return session.provider != nil && session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) {
|
func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) {
|
||||||
@@ -916,9 +927,7 @@ func (session *Session) validProtectedUDPSource(remote *net.UDPAddr) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expectedIP := addressIP(session.conn.RemoteAddr())
|
expectedIP := addressIP(session.conn.RemoteAddr())
|
||||||
return expectedIP != nil &&
|
return expectedIP != nil && expectedIP.Equal(remote.IP)
|
||||||
expectedIP.Equal(remote.IP) &&
|
|
||||||
remote.Port == session.securityAgreement.selected.portClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) effectiveSecurityMode() string {
|
func (session *Session) effectiveSecurityMode() string {
|
||||||
|
|||||||
@@ -531,8 +531,8 @@ func serveProtectedRegistrar(
|
|||||||
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
||||||
strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
!strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
||||||
return result, errors.New("registration transaction advertised or sent MESSAGE")
|
return result, errors.New("registration transaction did not advertise MESSAGE correctly")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := protectedConnection.Write(testResponse(
|
if _, err := protectedConnection.Write(testResponse(
|
||||||
|
|||||||
@@ -287,14 +287,18 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
|||||||
"tcp 40666 50600 out": false,
|
"tcp 40666 50600 out": false,
|
||||||
"udp 40666 50600 out": false,
|
"udp 40666 50600 out": false,
|
||||||
"tcp 50600 40666 in": false,
|
"tcp 50600 40666 in": false,
|
||||||
"tcp 50601 55610 in": false,
|
"tcp * 55610 in": false,
|
||||||
"udp 50601 55610 in": false,
|
"udp * 55610 in": false,
|
||||||
"tcp 55610 50601 out": false,
|
"tcp 55610 50601 out": false,
|
||||||
}
|
}
|
||||||
for _, operation := range install[4:] {
|
for _, operation := range install[4:] {
|
||||||
|
sourcePort := "*"
|
||||||
|
if value, ok := optionalArgumentAfter(operation.arguments, "sport"); ok {
|
||||||
|
sourcePort = value
|
||||||
|
}
|
||||||
key := strings.Join([]string{
|
key := strings.Join([]string{
|
||||||
argumentAfter(t, operation.arguments, "proto"),
|
argumentAfter(t, operation.arguments, "proto"),
|
||||||
argumentAfter(t, operation.arguments, "sport"),
|
sourcePort,
|
||||||
argumentAfter(t, operation.arguments, "dport"),
|
argumentAfter(t, operation.arguments, "dport"),
|
||||||
argumentAfter(t, operation.arguments, "dir"),
|
argumentAfter(t, operation.arguments, "dir"),
|
||||||
}, " ")
|
}, " ")
|
||||||
@@ -397,6 +401,15 @@ func argumentAfter(t *testing.T, arguments []string, name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalArgumentAfter(arguments []string, name string) (string, bool) {
|
||||||
|
for index := 0; index+1 < len(arguments); index++ {
|
||||||
|
if arguments[index] == name {
|
||||||
|
return arguments[index+1], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func containsArguments(arguments []string, sequence ...string) bool {
|
func containsArguments(arguments []string, sequence ...string) bool {
|
||||||
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -91,11 +91,14 @@ func (session *Session) startRuntimeReceivers() error {
|
|||||||
|
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.readMainConnection()
|
go session.readMainConnection()
|
||||||
if session.securityActive && session.transport == "tcp" && session.protectedTCP != nil {
|
// Vodafone UK (and others) deliver MT SMS as SIP MESSAGE to the
|
||||||
|
// ipsec-3gpp UE server port over UDP even when REGISTER used TCP.
|
||||||
|
// Always read both sockets when they were reserved.
|
||||||
|
if session.securityActive && session.protectedTCP != nil {
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.acceptProtectedTCP()
|
go session.acceptProtectedTCP()
|
||||||
}
|
}
|
||||||
if session.securityActive && session.transport == "udp" && session.protectedUDP != nil {
|
if session.securityActive && session.protectedUDP != nil {
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.readProtectedUDP()
|
go session.readProtectedUDP()
|
||||||
}
|
}
|
||||||
@@ -140,6 +143,8 @@ func (session *Session) acceptProtectedTCP() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound TCP rejected", nil,
|
||||||
|
"stage", "source_filter", "remote", connection.RemoteAddr().String())
|
||||||
_ = connection.Close()
|
_ = connection.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -186,6 +191,8 @@ func (session *Session) readProtectedUDP() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !session.validProtectedUDPSource(remote) {
|
if !session.validProtectedUDPSource(remote) {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound UDP rejected", nil,
|
||||||
|
"stage", "source_filter", "remote", remote.String(), "packet_bytes", count)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
packet, err := parseSIPPacket(buffer[:count])
|
packet, err := parseSIPPacket(buffer[:count])
|
||||||
@@ -208,8 +215,9 @@ func (session *Session) validProtectedTCPSource(address net.Addr) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expected := addressIP(session.conn.RemoteAddr())
|
expected := addressIP(session.conn.RemoteAddr())
|
||||||
return expected != nil && expected.Equal(remote.IP) &&
|
// Require P-CSCF IP. Do not require port-c (50601): some cores originate
|
||||||
remote.Port == session.securityAgreement.selected.portClient
|
// MESSAGE from an ephemeral port on the same P-CSCF.
|
||||||
|
return expected != nil && expected.Equal(remote.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) {
|
func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) {
|
||||||
@@ -755,7 +763,10 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
|||||||
|
|
||||||
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||||
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
||||||
return strings.TrimSpace(config.SMSCenterByPLMN[plmn])
|
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(vowifi.ResolveCarrierProfile(identity).SMSCenter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func smsRecipientType(recipient string) string {
|
func smsRecipientType(recipient string) string {
|
||||||
|
|||||||
@@ -180,6 +180,22 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
mnc string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{mnc: "10", want: "+447802000332"},
|
||||||
|
{mnc: "15", want: "+447785016005"},
|
||||||
|
{mnc: "30", want: ""},
|
||||||
|
} {
|
||||||
|
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
||||||
|
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
|
||||||
|
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
|
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
"vocat/internal/modem"
|
"vocat/internal/modem"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ATDeviceController interface {
|
type ATDeviceController interface {
|
||||||
@@ -73,6 +74,28 @@ func (mapper ATMapper) ExecuteSensitiveAT(
|
|||||||
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
|
||||||
|
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
|
||||||
|
// without issuing duplicate APDUs on every reconnect.
|
||||||
|
func (mapper ATMapper) ReadSIMMetadata(ctx context.Context, configuredID string) (vowifi.SIMMetadata, error) {
|
||||||
|
physicalID, err := mapper.resolve(ctx, configuredID)
|
||||||
|
if err != nil {
|
||||||
|
return vowifi.SIMMetadata{}, err
|
||||||
|
}
|
||||||
|
entry, err := mapper.Devices.Get(physicalID)
|
||||||
|
if err != nil {
|
||||||
|
return vowifi.SIMMetadata{}, err
|
||||||
|
}
|
||||||
|
if entry.Snapshot == nil {
|
||||||
|
return vowifi.SIMMetadata{}, nil
|
||||||
|
}
|
||||||
|
return vowifi.SIMMetadata{
|
||||||
|
SPN: strings.TrimSpace(entry.Snapshot.SPN),
|
||||||
|
GID1: strings.TrimSpace(entry.Snapshot.GID1),
|
||||||
|
GID2: strings.TrimSpace(entry.Snapshot.GID2),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (mapper ATMapper) resolve(
|
func (mapper ATMapper) resolve(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
configuredID string,
|
configuredID string,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func (resolver ProxyResolver) Resolve(
|
|||||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||||
}
|
}
|
||||||
var upstreamID string
|
var upstreamID string
|
||||||
|
matchedCountryRule := false
|
||||||
if iccid != "" {
|
if iccid != "" {
|
||||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -43,7 +44,10 @@ func (resolver ProxyResolver) Resolve(
|
|||||||
if upstreamID == "" {
|
if upstreamID == "" {
|
||||||
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
||||||
if !found {
|
if !found {
|
||||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
country = strings.ToUpper(strings.TrimSpace(request.CountryCode))
|
||||||
|
if len(country) != 2 {
|
||||||
|
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
||||||
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
||||||
@@ -53,6 +57,7 @@ func (resolver ProxyResolver) Resolve(
|
|||||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
||||||
}
|
}
|
||||||
upstreamID = rule.UpstreamProxyID
|
upstreamID = rule.UpstreamProxyID
|
||||||
|
matchedCountryRule = true
|
||||||
}
|
}
|
||||||
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,12 +69,43 @@ func (resolver ProxyResolver) Resolve(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if !upstream.Enabled {
|
if !upstream.Enabled {
|
||||||
|
if matchedCountryRule {
|
||||||
|
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||||
|
}
|
||||||
return vowifi.ProxyRoute{}, fmt.Errorf(
|
return vowifi.ProxyRoute{}, fmt.Errorf(
|
||||||
"upstream proxy %q for device %s is disabled",
|
"upstream proxy %q for device %s is disabled",
|
||||||
upstream.ID,
|
upstream.ID,
|
||||||
deviceID,
|
deviceID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if matchedCountryRule && iccid != "" {
|
||||||
|
created, bindErr := resolver.Store.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||||
|
DeviceID: deviceID,
|
||||||
|
ICCID: iccid,
|
||||||
|
ProfileName: iccid,
|
||||||
|
UpstreamProxyID: upstream.ID,
|
||||||
|
})
|
||||||
|
if bindErr != nil {
|
||||||
|
return vowifi.ProxyRoute{}, fmt.Errorf("materialize MCC proxy route for ICCID %s: %w", iccid, bindErr)
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
|
// Another request or an administrator may have created an explicit
|
||||||
|
// binding after our first lookup. The persisted ICCID route wins.
|
||||||
|
binding, bindingErr := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||||
|
if bindingErr != nil {
|
||||||
|
return vowifi.ProxyRoute{}, fmt.Errorf("reload proxy binding for ICCID %s: %w", iccid, bindingErr)
|
||||||
|
}
|
||||||
|
if binding.UpstreamProxyID != upstream.ID {
|
||||||
|
upstream, err = resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
|
||||||
|
if err != nil {
|
||||||
|
return vowifi.ProxyRoute{}, fmt.Errorf("load materialized upstream proxy %q for device %s: %w", binding.UpstreamProxyID, deviceID, err)
|
||||||
|
}
|
||||||
|
if !upstream.Enabled {
|
||||||
|
return vowifi.ProxyRoute{}, fmt.Errorf("upstream proxy %q for device %s is disabled", upstream.ID, deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return vowifi.ProxyRoute{
|
return vowifi.ProxyRoute{
|
||||||
Mode: vowifi.ProxyModeSOCKS5,
|
Mode: vowifi.ProxyModeSOCKS5,
|
||||||
ID: upstream.ID,
|
ID: upstream.ID,
|
||||||
@@ -198,6 +234,8 @@ func (projector StateProjector) Save(
|
|||||||
"pure_airplane_policy": state.PureAirplanePolicy,
|
"pure_airplane_policy": state.PureAirplanePolicy,
|
||||||
"home_mcc": state.HomeMCC,
|
"home_mcc": state.HomeMCC,
|
||||||
"home_mnc": state.HomeMNC,
|
"home_mnc": state.HomeMNC,
|
||||||
|
"carrier_profile": state.CarrierProfile,
|
||||||
|
"carrier_profile_from": state.CarrierProfileFrom,
|
||||||
"warnings": state.Warnings,
|
"warnings": state.Warnings,
|
||||||
"cleanup_errors": state.CleanupErrors,
|
"cleanup_errors": state.CleanupErrors,
|
||||||
"attempt": state.Attempt,
|
"attempt": state.Attempt,
|
||||||
|
|||||||
@@ -103,6 +103,141 @@ func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProxyResolverCountryRuleWithDisabledProxyFallsBackDirect(t *testing.T) {
|
||||||
|
database := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||||
|
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||||
|
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "disabled", Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
route, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if route.Mode != vowifi.ProxyModeDirect {
|
||||||
|
t.Fatalf("route = %#v, want direct for a disabled country default", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
|
||||||
|
database := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||||
|
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||||
|
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
||||||
|
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
|
||||||
|
database := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, proxy := range []store.UpstreamProxy{
|
||||||
|
{ID: "first", Name: "First", Addr: "127.0.0.1:1080", Enabled: true},
|
||||||
|
{ID: "later", Name: "Later", Addr: "127.0.0.1:1081", Enabled: true},
|
||||||
|
} {
|
||||||
|
if err := database.UpsertUpstreamProxy(ctx, proxy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||||
|
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "first", Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := vowifi.ProxyRequest{
|
||||||
|
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||||
|
}
|
||||||
|
resolver := ProxyResolver{Store: database}
|
||||||
|
route, err := resolver.Resolve(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if route.ID != "first" {
|
||||||
|
t.Fatalf("first route = %#v, want MCC default", route)
|
||||||
|
}
|
||||||
|
binding, err := database.DeviceProxyBinding(ctx, request.ICCID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if binding.DeviceID != request.DeviceID || binding.UpstreamProxyID != "first" {
|
||||||
|
t.Fatalf("materialized binding = %#v", binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||||
|
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "later", Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
route, err = resolver.Resolve(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if route.ID != "first" {
|
||||||
|
t.Fatalf("route after country rule edit = %#v, want durable ICCID binding", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testing.T) {
|
||||||
|
database := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, proxyID := range []string{"explicit", "default"} {
|
||||||
|
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||||
|
ID: proxyID, Name: proxyID, Addr: "127.0.0.1:1080", Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iccid := "89441000400128014257"
|
||||||
|
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||||
|
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
created, err := database.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||||
|
DeviceID: "ec20", ICCID: iccid, ProfileName: "Automatic", UpstreamProxyID: "default",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
t.Fatal("default binding unexpectedly replaced an explicit binding")
|
||||||
|
}
|
||||||
|
binding, err := database.DeviceProxyBinding(ctx, iccid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if binding.UpstreamProxyID != "explicit" || binding.ProfileName != "Manual" {
|
||||||
|
t.Fatalf("binding = %#v, want explicit binding unchanged", binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
||||||
database := testStore(t)
|
database := testStore(t)
|
||||||
for _, proxy := range []store.UpstreamProxy{
|
for _, proxy := range []store.UpstreamProxy{
|
||||||
@@ -216,13 +351,15 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
projector := StateProjector{Store: database}
|
projector := StateProjector{Store: database}
|
||||||
if err := projector.Save(context.Background(), vowifi.State{
|
if err := projector.Save(context.Background(), vowifi.State{
|
||||||
DeviceID: "ec25",
|
DeviceID: "ec25",
|
||||||
Phase: vowifi.PhaseIMSReady,
|
Phase: vowifi.PhaseIMSReady,
|
||||||
TunnelReady: true,
|
TunnelReady: true,
|
||||||
IMSReady: true,
|
IMSReady: true,
|
||||||
TunnelName: "vocat-swu-ec25",
|
TunnelName: "vocat-swu-ec25",
|
||||||
DataplaneMode: "userspace",
|
DataplaneMode: "userspace",
|
||||||
UpdatedAt: time.Now().UTC(),
|
CarrierProfile: "vodafone-uk",
|
||||||
|
CarrierProfileFrom: "hplmn",
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -240,6 +377,13 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
|||||||
if tunnel["dataplane_mode"] != "userspace" {
|
if tunnel["dataplane_mode"] != "userspace" {
|
||||||
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
|
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
|
||||||
}
|
}
|
||||||
|
var extra map[string]any
|
||||||
|
if err := json.Unmarshal(runtime.Extra, &extra); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if extra["carrier_profile"] != "vodafone-uk" || extra["carrier_profile_from"] != "hplmn" {
|
||||||
|
t.Fatalf("carrier profile projection = %#v", extra)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID stri
|
|||||||
return SIMIdentity{}, err
|
return SIMIdentity{}, err
|
||||||
}
|
}
|
||||||
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
|
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
|
||||||
|
if reader, ok := adapter.controller.(SIMMetadataReader); ok {
|
||||||
|
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||||
|
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||||
|
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||||
|
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||||
|
identity = applyAssignedCarrierRoute(identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := identity.validate(); err != nil {
|
if err := identity.validate(); err != nil {
|
||||||
return SIMIdentity{}, err
|
return SIMIdentity{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
|||||||
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
|
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
carrierProfile := ResolveCarrierProfile(identity)
|
||||||
orchestrator.mutate(func(state *State) {
|
orchestrator.mutate(func(state *State) {
|
||||||
state.Phase = PhaseSIMReady
|
state.Phase = PhaseSIMReady
|
||||||
state.ICCID = strings.TrimSpace(identity.ICCID)
|
state.ICCID = strings.TrimSpace(identity.ICCID)
|
||||||
@@ -248,6 +249,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
|||||||
state.SIMReady = true
|
state.SIMReady = true
|
||||||
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
||||||
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
||||||
|
state.CarrierProfile = carrierProfile.ID
|
||||||
|
state.CarrierProfileFrom = carrierProfile.MatchSource
|
||||||
state.LastReason = "sim_and_aka_ready"
|
state.LastReason = "sim_and_aka_ready"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -645,8 +648,12 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
|
|||||||
}
|
}
|
||||||
return strings.ToLower(configured), nil
|
return strings.ToLower(configured), nil
|
||||||
}
|
}
|
||||||
if IsATT310280(identity) {
|
profile := ResolveCarrierProfile(identity)
|
||||||
return att310280EPDG, nil
|
if profile.EPDG != "" {
|
||||||
|
return profile.EPDG, nil
|
||||||
|
}
|
||||||
|
if profile.RouteMCC != "" {
|
||||||
|
return standardEPDGHostname(profile.RouteMCC, profile.RouteMNC), nil
|
||||||
}
|
}
|
||||||
if err := identity.validate(); err != nil {
|
if err := identity.validate(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
|||||||
mncLength := identity.MNCLength
|
mncLength := identity.MNCLength
|
||||||
if mncLength != 2 && mncLength != 3 {
|
if mncLength != 2 && mncLength != 3 {
|
||||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
|
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC, SPN: identity.SPN}), nil
|
||||||
}
|
}
|
||||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
|||||||
return applyAssignedCarrierRoute(SIMIdentity{
|
return applyAssignedCarrierRoute(SIMIdentity{
|
||||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||||
SMSC: identity.SMSC,
|
SMSC: identity.SMSC, SPN: identity.SPN,
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ type State struct {
|
|||||||
PureAirplanePolicy bool `json:"pure_airplane_policy"`
|
PureAirplanePolicy bool `json:"pure_airplane_policy"`
|
||||||
HomeMCC string `json:"home_mcc,omitempty"`
|
HomeMCC string `json:"home_mcc,omitempty"`
|
||||||
HomeMNC string `json:"home_mnc,omitempty"`
|
HomeMNC string `json:"home_mnc,omitempty"`
|
||||||
|
CarrierProfile string `json:"carrier_profile,omitempty"`
|
||||||
|
CarrierProfileFrom string `json:"carrier_profile_from,omitempty"`
|
||||||
EPDG string `json:"epdg,omitempty"`
|
EPDG string `json:"epdg,omitempty"`
|
||||||
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
|
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
|
||||||
ProxyID string `json:"proxy_id,omitempty"`
|
ProxyID string `json:"proxy_id,omitempty"`
|
||||||
@@ -137,6 +139,9 @@ type SIMIdentity struct {
|
|||||||
HomeMCC string
|
HomeMCC string
|
||||||
HomeMNC string
|
HomeMNC string
|
||||||
HomeCountryCode string
|
HomeCountryCode string
|
||||||
|
SPN string
|
||||||
|
GID1 string
|
||||||
|
GID2 string
|
||||||
EPDG string
|
EPDG string
|
||||||
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
|
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
|
||||||
// RP-DATA. It is optional during identity discovery, but IMS submission
|
// RP-DATA. It is optional during identity discovery, but IMS submission
|
||||||
@@ -304,6 +309,22 @@ type SIMIdentityReader interface {
|
|||||||
ReadIdentity(context.Context, string) (SIMIdentity, error)
|
ReadIdentity(context.Context, string) (SIMIdentity, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SIMMetadata contains optional, non-secret carrier selectors stored by the
|
||||||
|
// UICC. They improve MVNO matching but are never required for AKA or exposed in
|
||||||
|
// the public runtime state.
|
||||||
|
type SIMMetadata struct {
|
||||||
|
SPN string
|
||||||
|
GID1 string
|
||||||
|
GID2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIMMetadataReader is an optional companion implemented by device mappers
|
||||||
|
// that already cache EF_SPN and EF_GID1/2. Identity readers degrade to PLMN,
|
||||||
|
// IMSI and ICCID matching when it is unavailable.
|
||||||
|
type SIMMetadataReader interface {
|
||||||
|
ReadSIMMetadata(context.Context, string) (SIMMetadata, error)
|
||||||
|
}
|
||||||
|
|
||||||
// SMSCenterReader optionally supplies the SIM-configured service-centre
|
// SMSCenterReader optionally supplies the SIM-configured service-centre
|
||||||
// address needed for mobile-originated SMS over IMS.
|
// address needed for mobile-originated SMS over IMS.
|
||||||
type SMSCenterReader interface {
|
type SMSCenterReader interface {
|
||||||
@@ -384,6 +405,7 @@ type Call struct {
|
|||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
AnsweredAt *time.Time `json:"answered_at,omitempty"`
|
||||||
SIPCode int `json:"sip_code,omitempty"`
|
SIPCode int `json:"sip_code,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
MediaReady bool `json:"media_ready,omitempty"`
|
MediaReady bool `json:"media_ready,omitempty"`
|
||||||
|
|||||||
@@ -195,6 +195,39 @@ install_linux_ip_tool() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_qmi_support() {
|
||||||
|
msg "正在检查 QMI 命令行工具..." "Checking QMI command-line utilities..."
|
||||||
|
if command -v qmicli >/dev/null 2>&1 && command -v qmi-network >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
||||||
|
opkg update >/dev/null 2>&1 || true
|
||||||
|
if opkg_has_package libqmi; then
|
||||||
|
opkg install libqmi >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y libqmi-utils || true
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y libqmi-utils || true
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
pacman -Sy --noconfirm libqmi || true
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache qmi-utils || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v qmicli >/dev/null 2>&1 && command -v qmi-network >/dev/null 2>&1; then
|
||||||
|
msg "QMI 命令行工具已就绪。" "QMI command-line utilities are ready."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
die \
|
||||||
|
"无法安装或找到 qmicli/qmi-network。请安装系统提供的 libqmi/qmi-utils 软件包后重试。" \
|
||||||
|
"Could not install or find qmicli/qmi-network. Install your distribution's libqmi/qmi-utils package and retry."
|
||||||
|
}
|
||||||
|
|
||||||
install_pcsc_support() {
|
install_pcsc_support() {
|
||||||
msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..."
|
msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..."
|
||||||
local installed=0
|
local installed=0
|
||||||
@@ -538,6 +571,7 @@ enable_and_start() {
|
|||||||
|
|
||||||
# --- Main --------------------------------------------------------------------
|
# --- Main --------------------------------------------------------------------
|
||||||
detect_arch
|
detect_arch
|
||||||
|
install_qmi_support
|
||||||
install_pcsc_support
|
install_pcsc_support
|
||||||
check_vowifi_environment
|
check_vowifi_environment
|
||||||
if [ "$CHECK_ENV" -eq 1 ]; then
|
if [ "$CHECK_ENV" -eq 1 ]; then
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbeddedDistributionContainsIndex(t *testing.T) {
|
||||||
|
index, err := fs.ReadFile(Dist, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read embedded index.html: %v", err)
|
||||||
|
}
|
||||||
|
if len(index) == 0 {
|
||||||
|
t.Fatal("embedded index.html is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ServerRegular } from "@fluentui/react-icons";
|
||||||
|
import type { DashboardHostInfo } from "../../types";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宿主机信息卡:CPU / 主板 / 内存 / 硬盘型号,后端一次性探测后缓存。
|
||||||
|
export function HostInfoCard({ info }: { info?: DashboardHostInfo | null }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ label: t("CPU 型号"), value: info?.cpuModel || "" },
|
||||||
|
{ label: t("主板型号"), value: info?.boardModel || "" },
|
||||||
|
{ label: t("内存型号"), value: info?.memoryModel || "" },
|
||||||
|
{ label: t("硬盘型号"), value: info?.diskModel || "" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<ServerRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("宿主机信息")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.label} className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="flex-shrink-0 text-xs text-gray-400">{row.label}</span>
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-right text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
title={row.value || undefined}
|
||||||
|
>
|
||||||
|
{row.value || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { ArrowDownRegular, ArrowUpRegular, GaugeRegular } from "@fluentui/react-icons";
|
||||||
|
import type { DashboardHostPerf } from "../../types";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import { cx, formatBytes } from "../../lib/utils";
|
||||||
|
|
||||||
|
function percentText(value: number) {
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 利用率越高颜色越危险:<70 绿,<90 黄,其余红。
|
||||||
|
function barColor(percent: number) {
|
||||||
|
if (percent >= 90) return "bg-red-500";
|
||||||
|
if (percent >= 70) return "bg-amber-500";
|
||||||
|
return "bg-emerald-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function textColor(percent: number) {
|
||||||
|
if (percent >= 90) return "text-red-600 dark:text-red-400";
|
||||||
|
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
|
||||||
|
return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageBar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
|
||||||
|
const clamped = Math.min(100, Math.max(0, percent || 0));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-xs text-gray-400">{label}</span>
|
||||||
|
<span className="flex items-baseline gap-1.5">
|
||||||
|
{detail ? <span className="text-[10px] text-gray-400">{detail}</span> : null}
|
||||||
|
<span className={cx("text-xs font-bold tabular-nums", textColor(clamped))}>{percentText(clamped)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className={cx("h-full rounded-full transition-all duration-500", barColor(clamped))}
|
||||||
|
style={{ width: `${clamped}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 性能信息卡:CPU / 内存 / 硬盘进度条 + 实时网络上下行速率。
|
||||||
|
export function HostPerfCard({ perf }: { perf?: DashboardHostPerf | null }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<GaugeRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("性能信息")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<UsageBar label={t("CPU 使用率")} percent={perf?.cpuPercent ?? 0} />
|
||||||
|
<UsageBar
|
||||||
|
label={t("内存使用率")}
|
||||||
|
percent={perf?.memoryPercent ?? 0}
|
||||||
|
detail={perf && perf.memoryTotalBytes > 0 ? `${formatBytes(perf.memoryUsedBytes)} / ${formatBytes(perf.memoryTotalBytes)}` : undefined}
|
||||||
|
/>
|
||||||
|
<UsageBar
|
||||||
|
label={t("硬盘使用率")}
|
||||||
|
percent={perf?.diskPercent ?? 0}
|
||||||
|
detail={perf && perf.diskTotalBytes > 0 ? `${formatBytes(perf.diskUsedBytes)} / ${formatBytes(perf.diskTotalBytes)}` : undefined}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-0.5">
|
||||||
|
<span className="text-xs text-gray-400">{t("网络")}</span>
|
||||||
|
<span className="flex items-center gap-3 text-xs font-semibold tabular-nums">
|
||||||
|
<span className="flex items-center gap-1 text-sky-600 dark:text-sky-400" title={t("实时上传速率")}>
|
||||||
|
<ArrowUpRegular className="h-3.5 w-3.5" />
|
||||||
|
{formatBytes(perf?.netTxBps ?? 0)}/s
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400" title={t("实时下载速率")}>
|
||||||
|
<ArrowDownRegular className="h-3.5 w-3.5" />
|
||||||
|
{formatBytes(perf?.netRxBps ?? 0)}/s
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
||||||
|
import { useI18n, tf } from "../../lib/i18n";
|
||||||
|
import { cx } from "../../lib/utils";
|
||||||
|
|
||||||
|
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
|
||||||
|
type RateLevel = "green" | "yellow" | "orange" | "red";
|
||||||
|
|
||||||
|
function rateLevel(percent: number): RateLevel {
|
||||||
|
if (percent >= 100) return "green";
|
||||||
|
if (percent >= 80) return "yellow";
|
||||||
|
if (percent >= 50) return "orange";
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
|
||||||
|
green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" },
|
||||||
|
yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" },
|
||||||
|
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
|
||||||
|
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。
|
||||||
|
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const percent = total > 0 ? Math.round((online / total) * 100) : null;
|
||||||
|
const level = percent === null ? null : rateLevel(percent);
|
||||||
|
const styles = level ? LEVEL_STYLES[level] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center py-1">
|
||||||
|
{percent === null ? (
|
||||||
|
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</div>
|
||||||
|
) : (
|
||||||
|
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
|
||||||
|
{percent}
|
||||||
|
<span className="text-2xl">%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{styles ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
|
||||||
|
{t(styles.labelKey)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CalendarClockRegular } from "@fluentui/react-icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { DashboardUpcomingTask } from "../../types";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
|
function formatRunAt(value: string): string {
|
||||||
|
if (!value || value.startsWith("0001-")) return "--";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "--";
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将要执行的定时任务卡:按 nextRunAt 升序取前 3 条已启用任务。
|
||||||
|
export function UpcomingTasksCard({ tasks }: { tasks: DashboardUpcomingTask[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarClockRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("将要执行的定时任务")}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/automatic-tasks")}
|
||||||
|
className="text-xs font-medium text-sky-600 transition-colors hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||||
|
>
|
||||||
|
{t("查看全部")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="flex h-[4.5rem] items-center justify-center text-xs text-gray-400">
|
||||||
|
{t("暂无定时任务")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-white/5">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300" title={task.name}>
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 font-mono text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||||
|
{formatRunAt(task.nextRunAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||||
|
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
||||||
|
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||||
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
|
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { SearchRegular } from "@fluentui/react-icons";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Country, CountryRule, UpstreamProxy } from "../../types";
|
||||||
|
import { Button, EmptyState, Input, Modal, Select } from "../ui";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
|
export interface CountryRulesDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
proxies: UpstreamProxy[];
|
||||||
|
countries: Country[];
|
||||||
|
rules: CountryRule[];
|
||||||
|
busy: boolean;
|
||||||
|
onSave: (assignments: Record<string, string>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountryRulesDialog(props: CountryRulesDialogProps) {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const { open, proxies, countries, rules, busy, onSave, onClose } = props;
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
|
const regionNames = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [lang]);
|
||||||
|
const countryLabel = (country: Country) => regionNames?.of(country.countryCode) || country.countryName || country.countryCode;
|
||||||
|
const proxyOptions = useMemo(() => [
|
||||||
|
{ value: "", label: t("直连") },
|
||||||
|
...proxies.map((proxy) => ({
|
||||||
|
value: proxy.id,
|
||||||
|
label: proxy.enabled ? (proxy.name || proxy.id) : `${proxy.name || proxy.id}(${t("已禁用")})`,
|
||||||
|
disabled: !proxy.enabled,
|
||||||
|
})),
|
||||||
|
], [proxies, t, lang]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery("");
|
||||||
|
setAssignments({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAssignments(Object.fromEntries(rules.filter((rule) => rule.enabled).map((rule) => [rule.countryCode, rule.upstreamProxyId])));
|
||||||
|
// Sample rules only when opening. Polling must not discard in-progress edits.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = query.trim().toLocaleLowerCase();
|
||||||
|
return [...countries]
|
||||||
|
.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b), lang === "zh" ? "zh-CN" : "en"))
|
||||||
|
.filter((country) => {
|
||||||
|
if (!needle) return true;
|
||||||
|
return [country.countryCode, country.countryName, countryLabel(country), ...country.mccs]
|
||||||
|
.some((value) => String(value || "").toLocaleLowerCase().includes(needle));
|
||||||
|
});
|
||||||
|
}, [countries, query, lang, regionNames]);
|
||||||
|
|
||||||
|
const configuredCount = Object.values(assignments).filter(Boolean).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("MCC 国家规则")}
|
||||||
|
width="max-w-5xl"
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} disabled={busy}>{t("取消")}</Button>
|
||||||
|
<Button variant="primary" loading={busy} onClick={() => onSave(assignments)}>{t("保存规则")}</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 pb-1">
|
||||||
|
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
|
||||||
|
{t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-xs text-gray-500">{configuredCount} {t("个国家规则")}</div>
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={t("搜索国家、地区代码或 MCC")}
|
||||||
|
prefix={<SearchRegular />}
|
||||||
|
className="w-full sm:w-72"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-white/10">
|
||||||
|
<div className="max-h-[55vh] overflow-auto">
|
||||||
|
<table className="w-full min-w-[680px] text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-[#202027]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">{t("国家 / 地区")}</th>
|
||||||
|
<th className="px-4 py-3">MCC</th>
|
||||||
|
<th className="w-72 px-4 py-3">{t("规则")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||||
|
{filtered.map((country) => (
|
||||||
|
<tr key={country.countryCode} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-medium">{countryLabel(country)}</span>
|
||||||
|
<span className="ml-2 font-mono text-xs text-gray-400">{country.countryCode}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{country.mccs.join(", ")}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Select
|
||||||
|
value={assignments[country.countryCode] || ""}
|
||||||
|
options={proxyOptions}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(value) => setAssignments((current) => ({ ...current, [country.countryCode]: value }))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 ? <EmptyState title={t("没有匹配的国家或 MCC")} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,23 +40,35 @@ function ProbeResultPanel({ probe }: { probe: UpstreamProbeResult }) {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const reachable = !!probe.reachable;
|
const reachable = !!probe.reachable;
|
||||||
const handshakeOk = !!probe.handshakeOk;
|
const handshakeOk = !!probe.handshakeOk;
|
||||||
const udpOk = !!probe.udpAssociateOk;
|
const associateOk = !!probe.udpAssociateOk;
|
||||||
|
const udpOk = !!probe.udpExchangeOk;
|
||||||
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
||||||
const udpState: ProbeState = !handshakeOk ? "pending" : udpOk ? "ok" : "fail";
|
const associateState: ProbeState = !handshakeOk ? "pending" : associateOk ? "ok" : "fail";
|
||||||
|
const udpState: ProbeState = !associateOk ? "pending" : udpOk ? "ok" : "fail";
|
||||||
return (
|
return (
|
||||||
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
||||||
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
||||||
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
||||||
<ProbeRow
|
<ProbeRow
|
||||||
state={udpState}
|
state={associateState}
|
||||||
label={t("UDP Associate(VoWiFi 依赖)")}
|
label={t("UDP Associate(VoWiFi 依赖)")}
|
||||||
detail={udpState === "pending" ? undefined : udpOk ? t("支持") : t("不支持")}
|
detail={associateState === "pending" ? undefined : associateOk ? t("已建立") : t("不支持")}
|
||||||
|
/>
|
||||||
|
<ProbeRow
|
||||||
|
state={udpState}
|
||||||
|
label={t("真实 UDP DNS 往返")}
|
||||||
|
detail={udpState === "pending" ? undefined : udpOk ? `${probe.roundTripMs || 0} ms` : t("无返回")}
|
||||||
/>
|
/>
|
||||||
{probe.relayAddr ? (
|
{probe.relayAddr ? (
|
||||||
<div className="text-[11px] text-gray-400">
|
<div className="text-[11px] text-gray-400">
|
||||||
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{probe.dnsName && probe.dnsServer ? (
|
||||||
|
<div className="text-[11px] text-gray-400">
|
||||||
|
{t("UDP 测试:")}<span className="font-mono">{probe.dnsName} @ {probe.dnsServer}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
||||||
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +139,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
|
|||||||
</div>
|
</div>
|
||||||
{probe ? (
|
{probe ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SectionHeader tone={probe.udpAssociateOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
<SectionHeader tone={probe.udpExchangeOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||||
<ProbeResultPanel probe={probe} />
|
<ProbeResultPanel probe={probe} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
|
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular, PauseRegular, PlayRegular } from "@fluentui/react-icons";
|
||||||
import type { UpstreamProxy } from "../../types";
|
import type { UpstreamProxy } from "../../types";
|
||||||
import { Button, Tag } from "../ui";
|
import { Button, Tag } from "../ui";
|
||||||
import type { LoadError, UpstreamRow } from "./shared";
|
import type { LoadError, UpstreamRow } from "./shared";
|
||||||
@@ -12,9 +12,11 @@ export interface UpstreamSectionProps {
|
|||||||
onEdit: (proxy: UpstreamProxy) => void;
|
onEdit: (proxy: UpstreamProxy) => void;
|
||||||
onDelete: (proxy: UpstreamProxy) => void;
|
onDelete: (proxy: UpstreamProxy) => void;
|
||||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||||
|
onToggle: (proxy: UpstreamProxy) => void;
|
||||||
|
toggleBusyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
|
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings, onToggle, toggleBusyId }: UpstreamSectionProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="ui-card overflow-hidden">
|
<div className="ui-card overflow-hidden">
|
||||||
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px] text-left text-sm">
|
<table className="w-full min-w-[760px] text-left text-sm">
|
||||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">{t("名称")}</th>
|
<th className="px-4 py-3">{t("名称")}</th>
|
||||||
<th className="px-4 py-3">{t("协议")}</th>
|
|
||||||
<th className="px-4 py-3">{t("地址")}</th>
|
<th className="px-4 py-3">{t("地址")}</th>
|
||||||
<th className="px-4 py-3">{t("鉴权")}</th>
|
|
||||||
<th className="px-4 py-3">{t("状态")}</th>
|
<th className="px-4 py-3">{t("状态")}</th>
|
||||||
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
|
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
|
||||||
|
<th className="px-4 py-3">{t("国家规则")}</th>
|
||||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
|||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||||
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
|
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
|
||||||
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
|
|
||||||
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
|
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
|
||||||
<td className="px-4 py-3">{row.username || t("无")}</td>
|
|
||||||
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
|
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
{row.bindingCount}
|
||||||
<DesktopRegular className="text-[14px]" />
|
</td>
|
||||||
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
|
<td className="px-4 py-3">
|
||||||
</div>
|
{row.countryNames.length ? (
|
||||||
|
<div className="flex max-w-sm flex-wrap gap-1">
|
||||||
|
{row.countryNames.map((countryName) => <Tag key={countryName} type="primary">{countryName}</Tag>)}
|
||||||
|
</div>
|
||||||
|
) : <span className="text-gray-400">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={row.enabled ? "warning" : "success"}
|
||||||
|
plain
|
||||||
|
icon={row.enabled ? <PauseRegular /> : <PlayRegular />}
|
||||||
|
loading={toggleBusyId === row.id}
|
||||||
|
onClick={() => onToggle(row)}
|
||||||
|
>{row.enabled ? t("禁用") : t("启用")}</Button>
|
||||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
|
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
|
||||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
||||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
||||||
@@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
|||||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||||
<GlobeRegular className="mb-3 text-4xl" />
|
<GlobeRegular className="mb-3 text-4xl" />
|
||||||
<div className="text-sm">{t("暂无上游代理")}</div>
|
<div className="text-sm">{t("暂无上游代理")}</div>
|
||||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
|
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ export interface UpstreamProbeResult {
|
|||||||
reachable?: boolean;
|
reachable?: boolean;
|
||||||
handshakeOk?: boolean;
|
handshakeOk?: boolean;
|
||||||
udpAssociateOk?: boolean;
|
udpAssociateOk?: boolean;
|
||||||
|
udpExchangeOk?: boolean;
|
||||||
authMethod?: string;
|
authMethod?: string;
|
||||||
relayAddr?: string;
|
relayAddr?: string;
|
||||||
|
dnsServer?: string;
|
||||||
|
dnsName?: string;
|
||||||
|
dnsRcode?: number;
|
||||||
|
roundTripMs?: number;
|
||||||
diagnosis?: string;
|
diagnosis?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -29,6 +34,7 @@ export interface UpstreamProbeResult {
|
|||||||
|
|
||||||
export interface UpstreamRow extends UpstreamProxy {
|
export interface UpstreamRow extends UpstreamProxy {
|
||||||
bindingCount: number;
|
bindingCount: number;
|
||||||
|
countryNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ipv6Hint(): string {
|
export function ipv6Hint(): string {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function PushplusTab({ value, onChange }: ChannelProps<PushplusForm>) {
|
|||||||
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
|
||||||
</div>
|
</div>
|
||||||
<Field label="Token">
|
<Field label="Token">
|
||||||
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
|
|||||||
import { Switch } from "../ui/Switch";
|
import { Switch } from "../ui/Switch";
|
||||||
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
|
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
|
||||||
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
|
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
|
||||||
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
|
import type { BarkForm, EmailForm, HeaderRow, LarkForm, WebhookForm, WecomForm } from "./model";
|
||||||
|
|
||||||
const HEADER_LIST_ID = "vocat-webhook-header-names";
|
const HEADER_LIST_ID = "vocat-webhook-header-names";
|
||||||
|
|
||||||
@@ -21,11 +21,11 @@ function hasAnyUrl(urls: string[]): boolean {
|
|||||||
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
|
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SMSOnlyHint() {
|
function OneWayNotificationHint() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ export function BarkTab({ value, onChange, testing, onTest }: PushChannelProps<B
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<UrlListEditor
|
<UrlListEditor
|
||||||
urls={value.urls}
|
urls={value.urls}
|
||||||
@@ -97,7 +97,7 @@ export function EmailTab({ value, onChange, testing, onTest }: PushChannelProps<
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
|
||||||
<Field label={t("SMTP 主机")} className="sm:col-span-5">
|
<Field label={t("SMTP 主机")} className="sm:col-span-5">
|
||||||
@@ -163,7 +163,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<UrlListEditor
|
<UrlListEditor
|
||||||
urls={value.urls}
|
urls={value.urls}
|
||||||
@@ -284,7 +284,7 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SMSOnlyHint />
|
<OneWayNotificationHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
|
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
|
||||||
@@ -300,7 +300,81 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
|
|||||||
label={t("JSON 请求体模板")}
|
label={t("JSON 请求体模板")}
|
||||||
hint={
|
hint={
|
||||||
<>
|
<>
|
||||||
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
|
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
|
||||||
|
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={value.payloadTemplate}
|
||||||
|
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
|
||||||
|
disabled={off}
|
||||||
|
rows={12}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LarkTab({ value, onChange, testing, onTest }: PushChannelProps<LarkForm>) {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const off = !value.enabled;
|
||||||
|
const complete = !!value.url.trim() && !!value.payloadTemplate.trim() && (!value.signingEnabled || !!value.secret.trim());
|
||||||
|
return (
|
||||||
|
<div className="pt-2">
|
||||||
|
<ChannelHeader
|
||||||
|
title={t("启用飞书 / Lark 群自定义机器人通知")}
|
||||||
|
enabled={value.enabled}
|
||||||
|
onToggle={(enabled) => onChange({ enabled })}
|
||||||
|
actions={
|
||||||
|
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
|
||||||
|
{t("测试通知")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<OneWayNotificationHint />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
|
{t("支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。")}
|
||||||
|
</div>
|
||||||
|
<Field label={t("群机器人 Webhook URL")}>
|
||||||
|
<Input
|
||||||
|
value={value.url}
|
||||||
|
onChange={(event) => {
|
||||||
|
const url = event.target.value;
|
||||||
|
onChange(value.url === "********" && url !== value.url ? { url, secret: "" } : { url });
|
||||||
|
}}
|
||||||
|
disabled={off}
|
||||||
|
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-bold uppercase tracking-wider text-gray-500">{t("启用签名校验")}</label>
|
||||||
|
<div className="flex h-10 items-center">
|
||||||
|
<Switch checked={value.signingEnabled} onChange={(signingEnabled) => onChange({ signingEnabled })} disabled={off} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{value.signingEnabled ? (
|
||||||
|
<Field
|
||||||
|
label={t("签名密钥 (Secret)")}
|
||||||
|
hint={t("填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={value.secret}
|
||||||
|
onChange={(event) => onChange({ secret: event.target.value })}
|
||||||
|
disabled={off}
|
||||||
|
type="password"
|
||||||
|
placeholder={t("群机器人签名密钥")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field
|
||||||
|
label={t("JSON 请求体模板")}
|
||||||
|
hint={
|
||||||
|
<>
|
||||||
|
{t("支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
|
||||||
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
|
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user