Compare commits

..
9 Commits
Author SHA1 Message Date
Rain SevenandGitHub 6ec950bfd2 style: gofmt IMS security policies (#44) 2026-08-17 00:14:42 +08:00
04dd026e95 test: align IMS registration expectations (#43)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 23:46:28 +08:00
a2a72dfd30 test: cover SMS state across restarts (#42)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 23:36:21 +08:00
67079c99b9 feat: add DJI QMI recovery doctor (#41)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 23:35:38 +08:00
f949001480 fix: support non-Quectel Qualcomm modems and fix 410 dongle AT timeouts (#40)
1. Vendor-neutral modem compatibility:
   - Discovery switched from a vendor-ID whitelist to detecting the QMI
     channel directly (an interface bound to the kernel qmi_wwan driver),
     so SIMCom, Sierra, Telit and other Qualcomm-based modules are found
     automatically while MBIM-only devices stay excluded
   - AT port responses now distinguish an AT command error from firmware
     incompatibility: ERROR / +CME ERROR is returned as a normal response
     (200) instead of being folded into a 502, which only a real transport
     failure produces

2. Fixed the 410 dongle's AT command timeouts:
   - Default WWAN AT port switched from wwan0at0 to wwan0at1: ModemManager
     marks the first AT port that answers its probe as primary (at1 on the
     tested UFI dongles) and closes AT ports once initialization finishes,
     so at1 is the responsive, idle channel for vocat while MM uses the
     QMI port for control
   - Drain the WWAN input buffer before each command write, discarding the
     late bytes of a previous timed-out command so they cannot pollute the
     next response's parsing
   - AT+CGSN now uses an independent short timeout instead of inheriting
     the refresh's 30s deadline (on MHI modems it returns the IMEI line
     but never a final OK). Previously every refresh held the device lock
     for the full 30s, queueing AT terminal commands behind it for 10-20s
   - The QMI UIM ICCID fallback only runs when AT+CPIN? already proved a
     READY card, so a SIM-less slot no longer blocks refresh waiting out
     its long timeout

Tests: added WWAN drain cleanup, drain-before-write ordering, CGSN timeout
bound, skip-QMI-ICCID-without-SIM, CommandError-as-200, WWAN at1 port
selection and vendor-neutral discovery cases. go vet and go test ./... pass.

Co-authored-by: Test <[email protected]>
2026-08-16 23:34:12 +08:00
fa8afb9571 fix: deliver Vodafone UK MT SMS over WiFi Calling (#39)
- Read ipsec-3gpp UDP server port even when REGISTER used TCP
- Advertise Allow MESSAGE and smsip Accept-Contact for all carriers
- Do not require P-CSCF port-c (50601) on inbound UE-server XFRM
- Log rejected inbound TCP/UDP sources

Verified on VOXI/Vodafone UK 23415 with Quectel EC25.

Closes #38

Co-authored-by: wl77vv <[email protected]>
2026-08-16 22:24:33 +08:00
bfda29193a feat: add operational health and metrics endpoints (#37)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 21:28:01 +08:00
ed64fd428a fix: restart the active systemd unit after update (#36)
Co-authored-by: Meng Meng <[email protected]>
2026-08-16 21:25:50 +08:00
Rain SevenandGitHub f84a1f99b1 feat: verify SOCKS5 with real UDP round trip (#35) 2026-08-16 21:24:14 +08:00
39 changed files with 2054 additions and 100 deletions
+3
View File
@@ -21,6 +21,9 @@ 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 safely wake a factory-ID
DJI/Baiwang 2ca3:4006 QMI interface without changing NV.
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.
+172
View File
@@ -0,0 +1,172 @@
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"`
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, "rebind DJI 2ca3:4006 interface 4 to qmi_wwan and 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_qmi_dtr_asserted", "DJI interface 4 was bound to qmi_wwan after a transient CDC DTR assertion; modem NV and USB identity were not changed", result)
}
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
}
+298
View File
@@ -0,0 +1,298 @@
//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"
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) {
return retryDJIQMI(ctx, 3, 500*time.Millisecond, func(attemptContext context.Context) (djiQMIRepairResult, error) {
return repairDJIQMIAt(attemptContext, "/sys", "/dev")
})
}
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 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))
result.OriginalDriver = usbInterfaceDriver(interfacePath)
if result.OriginalDriver != "" && result.OriginalDriver != "option" && result.OriginalDriver != "qmi_wwan" {
return result, fmt.Errorf("refusing to replace unexpected interface driver %q", result.OriginalDriver)
}
driversRoot := filepath.Join(sysRoot, "bus", "usb", "drivers")
if _, err := os.Stat(filepath.Join(driversRoot, "qmi_wwan")); err != nil {
modprobe, lookErr := exec.LookPath("modprobe")
if lookErr != nil {
return result, errors.New("qmi_wwan is not loaded and modprobe is unavailable")
}
if output, loadErr := exec.CommandContext(ctx, modprobe, "qmi_wwan").CombinedOutput(); loadErr != nil {
return result, fmt.Errorf("load qmi_wwan: %w: %s", loadErr, strings.TrimSpace(string(output)))
}
}
interfaceDetached := false
restoreOriginal := func() {
if !interfaceDetached {
return
}
if currentDriver := usbInterfaceDriver(interfacePath); currentDriver != "" {
_ = writeSysfs(filepath.Join(driversRoot, currentDriver, "unbind"), result.Interface)
}
if result.OriginalDriver != "" {
_ = 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
}
bindPath := filepath.Join(driversRoot, "qmi_wwan", "bind")
if err := writeSysfs(bindPath, result.Interface); err != nil {
newIDErr := writeSysfs(filepath.Join(driversRoot, "qmi_wwan", "new_id"), djiVendorID+" "+djiProductID)
if newIDErr != nil && !errors.Is(newIDErr, syscall.EEXIST) {
return result, fmt.Errorf("register DJI qmi_wwan dynamic ID after bind failure %v: %w", err, newIDErr)
}
if usbInterfaceDriver(interfacePath) != "qmi_wwan" {
if retryErr := writeSysfs(bindPath, result.Interface); retryErr != nil {
return result, fmt.Errorf("bind qmi_wwan to %s: %w", result.Interface, retryErr)
}
}
}
if driver := usbInterfaceDriver(interfacePath); driver != "qmi_wwan" {
return result, fmt.Errorf("interface %s driver is %q after qmi_wwan bind", result.Interface, driver)
}
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)
}
time.Sleep(250 * time.Millisecond)
qmicli, err := exec.LookPath("qmicli")
if err != nil {
return result, errors.New("qmicli is required to verify DJI QMI readiness after DTR repair")
}
probeContext, cancelProbe := context.WithTimeout(ctx, 8*time.Second)
output, probeErr := exec.CommandContext(probeContext, qmicli, "-d", result.ControlDevice, "--dms-get-operating-mode").CombinedOutput()
cancelProbe()
result.QMIProbe = strings.TrimSpace(string(output))
if probeErr != nil {
if probeContext.Err() != nil {
probeErr = errors.Join(probeErr, probeContext.Err())
}
return result, fmt.Errorf("DMS readiness check after DTR repair: %w: %s", probeErr, result.QMIProbe)
}
interfaceDetached = false
return result, 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 ""
}
+81
View File
@@ -0,0 +1,81 @@
//go:build linux
package main
import (
"context"
"errors"
"os"
"path/filepath"
"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 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)
}
}
+12
View File
@@ -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")
}
+9
View File
@@ -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)
}
}
+5
View File
@@ -77,6 +77,11 @@ 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 "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)
+18 -10
View File
@@ -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
+142
View File
@@ -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)
}
}
}
+10
View File
@@ -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."
}
+67 -16
View File
@@ -12,11 +12,9 @@ import (
) )
const ( const (
quectelVendorID = "2c7c" djiVendorID = "2ca3"
djiVendorID = "2ca3" dji4GProductID = "4006"
dji4GProductID = "4006"
) )
type SysFSDiscoverer struct { type SysFSDiscoverer struct {
SysRoot string SysRoot string
DevRoot string DevRoot string
@@ -44,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 {
@@ -75,7 +82,7 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
} }
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor"))) vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct"))) productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
if !isSupportedUSBModem(vendorID, productID) { if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
continue continue
} }
@@ -84,7 +91,7 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial")) serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
state = &discoveredUSBDevice{ state = &discoveredUSBDevice{
candidate: Candidate{ candidate: Candidate{
ID: candidateID(productID, serialNumber, deviceName), ID: candidateID(vendorID, productID, serialNumber, deviceName),
VendorID: vendorID, VendorID: vendorID,
ProductID: productID, ProductID: productID,
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")), Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
@@ -134,6 +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)
@@ -145,11 +160,6 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
return result, nil return result, nil
} }
func isSupportedUSBModem(vendorID, productID string) bool {
return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID) ||
IsDJI4GUSB(vendorID, productID)
}
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation // IsDJI4GUSB reports whether a USB identity belongs to the first-generation
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without // DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125. // requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
@@ -252,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])
@@ -263,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
@@ -402,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
@@ -411,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 {
+4 -4
View File
@@ -2,24 +2,24 @@ package modem
import "testing" import "testing"
func TestSupportedUSBModemIdentity(t *testing.T) { func TestIsDJI4GUSBIdentity(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
vendorID string vendorID string
productID string productID string
want bool want bool
}{ }{
{name: "Quectel", vendorID: "2c7c", productID: "0125", want: true},
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true}, {name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true}, {name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false}, {name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false}, {name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
if got := isSupportedUSBModem(test.vendorID, test.productID); got != test.want { if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
t.Fatalf("isSupportedUSBModem(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want) t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
} }
}) })
} }
+153 -3
View File
@@ -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)
}
}
+9 -4
View File
@@ -148,14 +148,19 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return response, err return response, err
} }
if err := writeAll(session.transport, []byte(command+"\r")); err != nil { // Drain the transport before writing the command. Serial transports wait
session.poisonLocked() // for any pending output here (a no-op after a synchronous command), while
return response, fmt.Errorf("write %s: %w", command, err) // WWAN transports discard bytes left over from a previous command that
} // timed out; without this, a late reply (e.g. a slow CGSN response) would
// be mis-parsed as this command's output.
if err := drainTransport(ctx, session.transport); err != nil { if err := drainTransport(ctx, session.transport); err != nil {
session.poisonLocked() session.poisonLocked()
return response, fmt.Errorf("drain %s: %w", command, err) return response, fmt.Errorf("drain %s: %w", command, err)
} }
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
session.poisonLocked()
return response, fmt.Errorf("write %s: %w", command, err)
}
return session.readFinalLocked(ctx, started, command, "", response) return session.readFinalLocked(ctx, started, command, "", response)
} }
+56
View File
@@ -453,6 +453,62 @@ func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) {
} }
} }
// drainOrderTransport forwards to an inner Transport while recording
// transport-level events, so a test can assert the exact order of Drain and
// Write calls.
type drainOrderTransport struct {
inner Transport
events chan string
}
func (transport *drainOrderTransport) Write(payload []byte) (int, error) {
transport.events <- "write:" + string(payload)
return transport.inner.Write(payload)
}
func (transport *drainOrderTransport) Read(buffer []byte) (int, error) {
return transport.inner.Read(buffer)
}
func (transport *drainOrderTransport) Drain() error {
transport.events <- "drain"
return transport.inner.Drain()
}
func (transport *drainOrderTransport) ResetInputBuffer() error {
return transport.inner.ResetInputBuffer()
}
func (transport *drainOrderTransport) SetReadTimeout(timeout time.Duration) error {
return transport.inner.SetReadTimeout(timeout)
}
func (transport *drainOrderTransport) Close() error {
return transport.inner.Close()
}
// WWAN transports discard stale bytes left over from a timed-out command
// inside Drain, so the session must call it before writing the next command;
// otherwise a late reply (e.g. a slow CGSN response) would be mis-parsed as
// the new command's output.
func TestSessionDrainsBeforeWritingCommand(t *testing.T) {
inner := &transcriptTransport{steps: []transportStep{{
write: "AT+CSQ\r",
chunks: []string{"\r\n+CSQ: 24,99\r\nOK\r\n"},
}}}
events := make(chan string, 8)
session := newTestSession(t, &drainOrderTransport{inner: inner, events: events})
if _, err := session.Execute(context.Background(), "AT+CSQ"); err != nil {
t.Fatalf("Execute: %v", err)
}
if first := <-events; first != "drain" {
t.Fatalf("first transport event = %q, want drain before the command write", first)
}
if second := <-events; second != "write:AT+CSQ\r" {
t.Fatalf("second transport event = %q, want the command write", second)
}
}
func newTestSession(t *testing.T, transport Transport) *Session { func newTestSession(t *testing.T, transport Transport) *Session {
t.Helper() t.Helper()
session, err := NewSession(transport, SessionOptions{ session, err := NewSession(transport, SessionOptions{
+21 -2
View File
@@ -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 {
+78
View File
@@ -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
View File
@@ -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:
+120
View File
@@ -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()
}
}
+69 -1
View File
@@ -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)
}
}
+20
View File
@@ -1059,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
} }
+52
View File
@@ -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())
}
+67
View File
@@ -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)
}
}
}
+6 -6
View File
@@ -395,8 +395,8 @@ func (s *Server) saveAndProbeUpstream(
) )
probeResponse := probeMap(probe, probeErr) probeResponse := probeMap(probe, probeErr)
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。") message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
if probeErr == nil && probe.UDPAssociateOK { if probeErr == nil && probe.UDPExchangeOK {
message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。") message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。")
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{ "data": map[string]any{
@@ -425,8 +425,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id
8*time.Second, 8*time.Second,
) )
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。") message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
if probeErr == nil && result.UDPAssociateOK { if probeErr == nil && result.UDPExchangeOK {
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。") message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{ "data": map[string]any{
@@ -479,8 +479,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques
8*time.Second, 8*time.Second,
) )
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。") message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
if probeErr == nil && result.UDPAssociateOK { if probeErr == nil && result.UDPExchangeOK {
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。") message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{ "data": map[string]any{
+3
View File
@@ -144,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)
+121
View File
@@ -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)
}
}
+42
View File
@@ -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)
}
}
}
+50 -2
View File
@@ -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.
+7 -1
View File
@@ -946,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
@@ -987,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,
+7 -1
View File
@@ -375,7 +375,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
return fmt.Errorf("unexpected start line %q", startLine) return fmt.Errorf("unexpected start line %q", startLine)
} }
for _, forbidden := range []string{ for _, forbidden := range []string{
"p-access-network-info",
"p-visited-network-id", "p-visited-network-id",
"p-preferred-identity", "p-preferred-identity",
} { } {
@@ -387,6 +386,13 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
) )
} }
} }
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
}
if !strings.Contains(headers["allow"], "MESSAGE") ||
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
}
if step == 0 { if step == 0 {
if headers["authorization"] != "" { if headers["authorization"] != "" {
return errors.New("initial REGISTER unexpectedly authenticated") return errors.New("initial REGISTER unexpectedly authenticated")
+42 -33
View File
@@ -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"},
@@ -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(
+16 -3
View File
@@ -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
+12 -4
View File
@@ -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) {
+17 -5
View File
@@ -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 AssociateVoWiFi 依赖)")} label={t("UDP AssociateVoWiFi 依赖)")}
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}
+5
View File
@@ -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;
+5
View File
@@ -684,6 +684,11 @@ export const EN_DICT: Record<string, string> = {
"SIM / 设备": "SIM / Device", "SIM / 设备": "SIM / Device",
"SM-DP+ 地址 *": "SM-DP+ Address *", "SM-DP+ 地址 *": "SM-DP+ Address *",
"SOCKS5 认证与 UDP ASSOCIATE 探测通过": "SOCKS5 auth and UDP ASSOCIATE probes passed", "SOCKS5 认证与 UDP ASSOCIATE 探测通过": "SOCKS5 auth and UDP ASSOCIATE probes passed",
"SOCKS5 认证与真实 UDP 往返探测通过": "SOCKS5 authentication and real UDP round-trip probe passed",
"真实 UDP DNS 往返": "Real UDP DNS round trip",
"已建立": "Established",
"无返回": "No response",
"UDP 测试:": "UDP test: ",
"Telegram / Bark / Email / Pushplus / Webhook": "Telegram / Bark / Email / Pushplus / Webhook", "Telegram / Bark / Email / Pushplus / Webhook": "Telegram / Bark / Email / Pushplus / Webhook",
"USB 路径": "USB Path", "USB 路径": "USB Path",
"USSD 交互终端": "USSD Interactive Terminal", "USSD 交互终端": "USSD Interactive Terminal",
+2 -2
View File
@@ -168,8 +168,8 @@ export default function ProxyPage() {
}, },
}); });
setUpstreamProbe(data.probe || null); setUpstreamProbe(data.probe || null);
if (data.probe?.udpAssociateOk) { if (data.probe?.udpExchangeOk) {
message.success(data.message || t("SOCKS5 鉴权和 UDP Associate 探测通过")); message.success(data.message || t("SOCKS5 认证与真实 UDP 往返探测通过"));
} else { } else {
message.warning(data.message || t("代理不能承载 VoWiFi 所需的 UDP")); message.warning(data.message || t("代理不能承载 VoWiFi 所需的 UDP"));
} }
+5
View File
@@ -330,8 +330,13 @@ export interface UpstreamProxyProbe {
reachable?: boolean; reachable?: boolean;
handshakeOk?: boolean; handshakeOk?: boolean;
udpAssociateOk?: boolean; udpAssociateOk?: boolean;
udpExchangeOk?: boolean;
authMethod?: string; authMethod?: string;
relayAddr?: string; relayAddr?: string;
dnsServer?: string;
dnsName?: string;
dnsRcode?: number;
roundTripMs?: number;
diagnosis?: string; diagnosis?: string;
hint?: string; hint?: string;
error?: string; error?: string;