mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-17 13:23:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec950bfd2 | ||
|
|
04dd026e95 | ||
|
|
a2a72dfd30 | ||
|
|
67079c99b9 | ||
|
|
f949001480 | ||
|
|
fa8afb9571 | ||
|
|
bfda29193a | ||
|
|
ed64fd428a | ||
|
|
f84a1f99b1 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func repairDJIQMI(context.Context) (djiQMIRepairResult, error) {
|
||||||
|
return djiQMIRepairResult{}, errors.New("DJI QMI repair is supported only on Linux")
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDoctorHelpIsSuccessful(t *testing.T) {
|
||||||
|
if err := runDoctor([]string{"--help"}); err != nil {
|
||||||
|
t.Fatalf("runDoctor(--help) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -69,7 +69,12 @@ func (manager *Manager) readSnapshot(
|
|||||||
if ccidErr != nil {
|
if ccidErr != nil {
|
||||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||||
}
|
}
|
||||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
|
||||||
|
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
|
||||||
|
// timeout, and every refresh holds the device lock while it does so,
|
||||||
|
// starving the AT terminal. Only fall back to QMI when the AT CPIN
|
||||||
|
// probe already proved a card is present.
|
||||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||||
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
||||||
cancelQMI()
|
cancelQMI()
|
||||||
@@ -186,14 +191,18 @@ func (manager *Manager) readSnapshot(
|
|||||||
snapshot.RegistrationSource = "COPS"
|
snapshot.RegistrationSource = "COPS"
|
||||||
}
|
}
|
||||||
if snapshot.IMEI == "" {
|
if snapshot.IMEI == "" {
|
||||||
response, ok := optional("AT+CGSN")
|
// AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
|
||||||
if ok {
|
// returns the IMEI line but never a final OK, so it would block until the
|
||||||
snapshot.IMEI = parseIdentifier(
|
// caller's deadline (30s during a periodic refresh) and starve every other
|
||||||
response,
|
// device operation behind the lock. Give it an independent short timeout
|
||||||
[]string{"+CGSN:", "+GSN:"},
|
// and let the WWAN transport's drain discard the trailing stale bytes.
|
||||||
14,
|
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
|
||||||
17,
|
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
|
||||||
)
|
cancelCGSN()
|
||||||
|
if cgsnErr == nil {
|
||||||
|
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
|
||||||
|
snapshot.IMEI = imei
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||||
@@ -211,7 +220,6 @@ func (manager *Manager) readSnapshot(
|
|||||||
// Preserve a prior successful read across a transient QMI/AT failure.
|
// Preserve a prior successful read across a transient QMI/AT failure.
|
||||||
snapshot.IMEI = previousSnapshot.IMEI
|
snapshot.IMEI = previousSnapshot.IMEI
|
||||||
}
|
}
|
||||||
|
|
||||||
if response, ok := optional("AT+CFUN?"); ok {
|
if response, ok := optional("AT+CFUN?"); ok {
|
||||||
if mode, found := parseCFUN(response); found {
|
if mode, found := parseCFUN(response); found {
|
||||||
snapshot.OperatingMode = mode
|
snapshot.OperatingMode = mode
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/modem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lenientATClient answers every command with a bare CommandError and records
|
||||||
|
// the commands it saw. It lets snapshot tests exercise the full readSnapshot
|
||||||
|
// sequence without enumerating every step of the transcript.
|
||||||
|
type lenientATClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
commands []string
|
||||||
|
cgsnDelay time.Duration
|
||||||
|
cgsnIMEI string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) Execute(ctx context.Context, command string) (modem.Response, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.commands = append(c.commands, command)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if command == "ATI" {
|
||||||
|
return okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410"), nil
|
||||||
|
}
|
||||||
|
if command == "AT+CGSN" && c.cgsnDelay > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(c.cgsnDelay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if command == "AT+CGSN" && c.cgsnIMEI != "" {
|
||||||
|
return okResponse("+CGSN: " + c.cgsnIMEI), nil
|
||||||
|
}
|
||||||
|
return modem.Response{}, &modem.CommandError{Command: command, Final: "ERROR"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) WaitURC(context.Context, func(string) bool) (string, error) {
|
||||||
|
return "", errors.New("no URC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lenientATClient) Close() error { return nil }
|
||||||
|
|
||||||
|
func (c *lenientATClient) saw(command string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for _, seen := range c.commands {
|
||||||
|
if seen == command {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT+CGSN on some MHI modems returns the IMEI line but never a final OK, so it
|
||||||
|
// would block until the caller's deadline and hold the device lock for the
|
||||||
|
// whole periodic refresh. The snapshot must bound CGSN with its own short
|
||||||
|
// timeout instead of inheriting the refresh deadline.
|
||||||
|
func TestManagerRefreshBoundsCGSNTimeout(t *testing.T) {
|
||||||
|
client := &lenientATClient{cgsnDelay: 5 * time.Second}
|
||||||
|
manager, id := newStartedTestManager(t, client)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
snapshot, err := manager.Refresh(ctx, id)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
// CGSN times out after CommandTimeout (1s in the test manager); the rest
|
||||||
|
// of the snapshot is immediate. An un-bounded CGSN would wait for the
|
||||||
|
// 4s outer deadline (or worse, a real 30s refresh deadline).
|
||||||
|
if elapsed > 3*time.Second {
|
||||||
|
t.Fatalf("Refresh took %s; CGSN was not bounded by CommandTimeout", elapsed)
|
||||||
|
}
|
||||||
|
if !client.saw("AT+CGSN") {
|
||||||
|
t.Fatalf("CGSN was never sent; commands = %v", client.commands)
|
||||||
|
}
|
||||||
|
if snapshot.IMEI != "" {
|
||||||
|
t.Fatalf("IMEI = %q, want empty after CGSN timeout", snapshot.IMEI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing SIM must not fall back to the QMI UIM ICCID read: without a READY
|
||||||
|
// card that call blocks until its long timeout and starves the AT terminal
|
||||||
|
// behind the device lock.
|
||||||
|
func TestManagerRefreshSkipsQMIICCIDWithoutReadySIM(t *testing.T) {
|
||||||
|
// CGSN succeeds so the snapshot does not fall back to the QMI DMS IMEI
|
||||||
|
// read either; the test focuses on the UIM ICCID fallback being skipped
|
||||||
|
// without a READY card.
|
||||||
|
client := &lenientATClient{cgsnIMEI: "866241014372802"}
|
||||||
|
manager, err := NewManager(Options{
|
||||||
|
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
|
||||||
|
ID: "mhi-wwan0",
|
||||||
|
Product: "PCIe/MHI WWAN modem",
|
||||||
|
QMIControl: "/dev/wwan0qmi0",
|
||||||
|
NetworkInterface: "wwan0",
|
||||||
|
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
|
||||||
|
}}},
|
||||||
|
Opener: &staticOpener{client: client},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := manager.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||||
|
|
||||||
|
qmiCalls := 0
|
||||||
|
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||||
|
qmiCalls++
|
||||||
|
return nil, errors.New("QMI should not be opened without a SIM")
|
||||||
|
}
|
||||||
|
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
// Exactly one QMI open is expected: the immutable DMS IMEI read runs
|
||||||
|
// unconditionally for native QMI candidates (IMEI is hardware identity,
|
||||||
|
// independent of the card). The UIM ICCID fallback, which would block
|
||||||
|
// without a READY SIM, must be skipped.
|
||||||
|
if qmiCalls != 1 {
|
||||||
|
t.Fatalf("qmiRadioOpener called %d times, want 1 (DMS IMEI only, UIM ICCID must be skipped without a READY SIM)", qmiCalls)
|
||||||
|
}
|
||||||
|
for _, warning := range snapshot.Warnings {
|
||||||
|
if strings.Contains(warning, "QMI UIM") {
|
||||||
|
t.Fatalf("unexpected QMI ICCID warning: %q", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
// Keep feature-specific diagnostic strings together so additions to the proxy
|
||||||
|
// probe do not cause conflicts in the shared dictionary.
|
||||||
|
func init() {
|
||||||
|
zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall."
|
||||||
|
zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed."
|
||||||
|
zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed."
|
||||||
|
zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed."
|
||||||
|
}
|
||||||
+67
-16
@@ -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 {
|
||||||
|
|||||||
@@ -2,24 +2,24 @@ package modem
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestSupportedUSBModemIdentity(t *testing.T) {
|
func TestIsDJI4GUSBIdentity(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
vendorID string
|
vendorID string
|
||||||
productID string
|
productID string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{name: "Quectel", vendorID: "2c7c", productID: "0125", want: true},
|
|
||||||
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
||||||
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
||||||
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
||||||
|
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
|
||||||
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if got := isSupportedUSBModem(test.vendorID, test.productID); got != test.want {
|
if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
|
||||||
t.Fatalf("isSupportedUSBModem(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||||
|
|
||||||
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
||||||
candidates, err := discoverer.Discover(context.Background())
|
candidates, err := discoverer.Discover(context.Background())
|
||||||
@@ -57,7 +59,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
|||||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
}
|
}
|
||||||
candidate := candidates[0]
|
candidate := candidates[0]
|
||||||
if candidate.ID != "quectel-0125-1-6" {
|
if candidate.ID != "usb-2c7c-0125-1-6" {
|
||||||
t.Fatalf("ID = %q", candidate.ID)
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Name != "ttyUSB2" {
|
if candidate.ATPort.Name != "ttyUSB2" {
|
||||||
@@ -101,6 +103,7 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
|||||||
)
|
)
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,6 +149,7 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, modem.usbName+":1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -194,6 +198,7 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
}
|
}
|
||||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, item.usbName+":1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
@@ -216,9 +221,11 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||||
|
// A plain USB serial adapter (FTDI) exposes ttyUSB but no QMI interface and
|
||||||
|
// is never bound to qmi_wwan, so it must not be treated as a modem.
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
||||||
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
||||||
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
||||||
@@ -235,6 +242,117 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryFindsNonQuectelVendorBoundToQMIWWAN(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// A Sierra EM7430 flashed to its QMI (rmnet0) composition: non-Quectel
|
||||||
|
// vendor, but its control interface is bound to qmi_wwan.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "idVendor"), "1199\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "idProduct"), "9077\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "manufacturer"), "Sierra Wireless, Incorporated\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-3", "product"), "EM7430\n")
|
||||||
|
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||||
|
interfaceName := "1-3:1." + strconv.Itoa(number)
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
|
}
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-3:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-3:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.VendorID != "1199" || candidate.Product != "EM7430" {
|
||||||
|
t.Fatalf("candidate = %#v", candidate)
|
||||||
|
}
|
||||||
|
if candidate.ID != "usb-1199-9077-1-3" {
|
||||||
|
t.Fatalf("ID = %q", candidate.ID)
|
||||||
|
}
|
||||||
|
if candidate.ATPort.Role != PortRoleAT {
|
||||||
|
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
if candidate.DiscoveryIssue != "" {
|
||||||
|
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryMarksQMIModemWithoutATPort(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// QMI control interface is bound, but no ttyUSB/ttyACM node exists (for
|
||||||
|
// example the option/qcserial driver does not claim the serial interfaces).
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-7", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-7", "idProduct"), "0125\n")
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-7:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-7:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||||
|
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||||
|
}
|
||||||
|
if candidate.HasATPort() {
|
||||||
|
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSysFSDiscoveryFindsHubAttachedQMIWWANDevice(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sysRoot := filepath.Join(root, "sys")
|
||||||
|
devRoot := filepath.Join(root, "dev")
|
||||||
|
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||||
|
// Device behind a hub: the USB path "1-4.3.2" contains extra segments, and
|
||||||
|
// the qmi_wwan binding uses the same composite path before the colon.
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idVendor"), "2c7c\n")
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idProduct"), "0125\n")
|
||||||
|
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||||
|
interfaceName := "1-4.3.2:1." + strconv.Itoa(number)
|
||||||
|
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||||
|
}
|
||||||
|
mustMkdir(t, filepath.Join(usbRoot, "1-4.3.2:1.4", "usbmisc", "cdc-wdm0"))
|
||||||
|
mustBindQMIWWAN(t, sysRoot, "1-4.3.2:1.4")
|
||||||
|
|
||||||
|
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Discover: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
candidate := candidates[0]
|
||||||
|
if candidate.ATPort.Name != "ttyUSB2" {
|
||||||
|
t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort)
|
||||||
|
}
|
||||||
|
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||||
|
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||||
|
}
|
||||||
|
if !strings.Contains(candidate.ID, "1-4-3-2") {
|
||||||
|
t.Fatalf("ID = %q, want hub topology in discovery key", candidate.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
sysRoot := filepath.Join(root, "sys")
|
sysRoot := filepath.Join(root, "sys")
|
||||||
@@ -256,7 +374,7 @@ func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
|||||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||||
t.Fatalf("identity = %#v", candidate)
|
t.Fatalf("identity = %#v", candidate)
|
||||||
}
|
}
|
||||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at1") || candidate.ATPort.Role != PortRoleAT {
|
||||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||||
}
|
}
|
||||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||||
@@ -290,6 +408,23 @@ func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectWWANATPortPrefersSecondaryATPort(t *testing.T) {
|
||||||
|
ports := []Port{
|
||||||
|
{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT},
|
||||||
|
{Name: "wwan0at1", InterfaceNumber: 1, Role: PortRoleAT},
|
||||||
|
}
|
||||||
|
if got := selectWWANATPort(ports); got.Name != "wwan0at1" {
|
||||||
|
t.Fatalf("selectWWANATPort = %#v, want wwan0at1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectWWANATPortFallsBackToPrimaryWhenOnlyAT0(t *testing.T) {
|
||||||
|
ports := []Port{{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT}}
|
||||||
|
if got := selectWWANATPort(ports); got.Name != "wwan0at0" {
|
||||||
|
t.Fatalf("selectWWANATPort = %#v, want wwan0at0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseWWANPortName(t *testing.T) {
|
func TestParseWWANPortName(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name, index, kind string
|
name, index, kind string
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,14 +148,19 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
// Drain the transport before writing the command. Serial transports wait
|
||||||
session.poisonLocked()
|
// for any pending output here (a no-op after a synchronous command), while
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
// WWAN transports discard bytes left over from a previous command that
|
||||||
}
|
// timed out; without this, a late reply (e.g. a slow CGSN response) would
|
||||||
|
// be mis-parsed as this command's output.
|
||||||
if err := drainTransport(ctx, session.transport); err != nil {
|
if err := drainTransport(ctx, session.transport); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
|
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||||
|
session.poisonLocked()
|
||||||
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
|
}
|
||||||
return session.readFinalLocked(ctx, started, command, "", response)
|
return session.readFinalLocked(ctx, started, command, "", response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -453,6 +453,62 @@ func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainOrderTransport forwards to an inner Transport while recording
|
||||||
|
// transport-level events, so a test can assert the exact order of Drain and
|
||||||
|
// Write calls.
|
||||||
|
type drainOrderTransport struct {
|
||||||
|
inner Transport
|
||||||
|
events chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Write(payload []byte) (int, error) {
|
||||||
|
transport.events <- "write:" + string(payload)
|
||||||
|
return transport.inner.Write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Read(buffer []byte) (int, error) {
|
||||||
|
return transport.inner.Read(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Drain() error {
|
||||||
|
transport.events <- "drain"
|
||||||
|
return transport.inner.Drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) ResetInputBuffer() error {
|
||||||
|
return transport.inner.ResetInputBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) SetReadTimeout(timeout time.Duration) error {
|
||||||
|
return transport.inner.SetReadTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *drainOrderTransport) Close() error {
|
||||||
|
return transport.inner.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WWAN transports discard stale bytes left over from a timed-out command
|
||||||
|
// inside Drain, so the session must call it before writing the next command;
|
||||||
|
// otherwise a late reply (e.g. a slow CGSN response) would be mis-parsed as
|
||||||
|
// the new command's output.
|
||||||
|
func TestSessionDrainsBeforeWritingCommand(t *testing.T) {
|
||||||
|
inner := &transcriptTransport{steps: []transportStep{{
|
||||||
|
write: "AT+CSQ\r",
|
||||||
|
chunks: []string{"\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||||
|
}}}
|
||||||
|
events := make(chan string, 8)
|
||||||
|
session := newTestSession(t, &drainOrderTransport{inner: inner, events: events})
|
||||||
|
if _, err := session.Execute(context.Background(), "AT+CSQ"); err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if first := <-events; first != "drain" {
|
||||||
|
t.Fatalf("first transport event = %q, want drain before the command write", first)
|
||||||
|
}
|
||||||
|
if second := <-events; second != "write:AT+CSQ\r" {
|
||||||
|
t.Fatalf("second transport event = %q, want the command write", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestSession(t *testing.T, transport Transport) *Session {
|
func newTestSession(t *testing.T, transport Transport) *Session {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
session, err := NewSession(transport, SessionOptions{
|
session, err := NewSession(transport, SessionOptions{
|
||||||
|
|||||||
@@ -108,8 +108,27 @@ func (transport *nativeWWANATTransport) Drain() error {
|
|||||||
return io.ErrClosedPipe
|
return io.ErrClosedPipe
|
||||||
}
|
}
|
||||||
// WWAN character-device writes are handed to the modem synchronously and
|
// WWAN character-device writes are handed to the modem synchronously and
|
||||||
// have no termios output queue to drain.
|
// have no termios output queue to drain. A previous command that timed out
|
||||||
return nil
|
// can leave late bytes in the input buffer (e.g. a slow CGSN reply that
|
||||||
|
// arrives after the command deadline); discard them here so the next
|
||||||
|
// command starts from a clean stream instead of mis-parsing stale output.
|
||||||
|
buffer := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||||
|
ready, err := unix.Poll(fds, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := unix.Read(transport.fd, buffer); err != nil {
|
||||||
|
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package modem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNativeWWANATTransportDrainDiscardsPendingBytes verifies Drain discards
|
||||||
|
// every byte already buffered on the transport. A command that timed out (e.g.
|
||||||
|
// AT+CGSN on an MHI modem that never answers OK) can leave its late reply in
|
||||||
|
// the input buffer; the next command's Drain must clear it, however much data
|
||||||
|
// is pending, before the session writes the new command.
|
||||||
|
func TestNativeWWANATTransportDrainDiscardsPendingBytes(t *testing.T) {
|
||||||
|
readFD, writeFD := socketpair(t)
|
||||||
|
defer unix.Close(writeFD)
|
||||||
|
|
||||||
|
// More than one 4096-byte Drain read: a slow CGSN reply (echo + IMEI +
|
||||||
|
// trailing CRLF) can exceed a single buffer.
|
||||||
|
payload := make([]byte, 12000)
|
||||||
|
for index := range payload {
|
||||||
|
payload[index] = byte('A' + index%26)
|
||||||
|
}
|
||||||
|
payload = append(payload, []byte("\r\n+CGSN: 357091089453326\r\n")...)
|
||||||
|
if _, err := unix.Write(writeFD, payload); err != nil {
|
||||||
|
t.Fatalf("seed stale bytes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||||
|
if err := transport.Drain(); err != nil {
|
||||||
|
t.Fatalf("Drain: %v", err)
|
||||||
|
}
|
||||||
|
assertNoPendingBytes(t, readFD, "after Drain")
|
||||||
|
|
||||||
|
// Draining a clean transport is a fast no-op that must not block or error.
|
||||||
|
if err := transport.Drain(); err != nil {
|
||||||
|
t.Fatalf("second Drain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNativeWWANATTransportDrainRejectsClosedTransport covers the guard that
|
||||||
|
// keeps a poisoned session from draining a wedged, already-closed fd.
|
||||||
|
func TestNativeWWANATTransportDrainRejectsClosedTransport(t *testing.T) {
|
||||||
|
readFD, writeFD := socketpair(t)
|
||||||
|
defer unix.Close(writeFD)
|
||||||
|
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||||
|
if err := transport.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
if err := transport.Drain(); !errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
t.Fatalf("Drain after Close = %v, want ErrClosedPipe", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func socketpair(t *testing.T) (int, int) {
|
||||||
|
t.Helper()
|
||||||
|
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return fds[0], fds[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoPendingBytes(t *testing.T, fd int, context string) {
|
||||||
|
t.Helper()
|
||||||
|
fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
||||||
|
ready, err := unix.Poll(fds, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("poll %s: %v", context, err)
|
||||||
|
}
|
||||||
|
if ready != 0 {
|
||||||
|
t.Fatalf("%s: fd still readable", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
-1
@@ -3,6 +3,8 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -17,18 +19,44 @@ type ProbeResult struct {
|
|||||||
Reachable bool `json:"reachable"`
|
Reachable bool `json:"reachable"`
|
||||||
HandshakeOK bool `json:"handshake_ok"`
|
HandshakeOK bool `json:"handshake_ok"`
|
||||||
UDPAssociateOK bool `json:"udp_associate_ok"`
|
UDPAssociateOK bool `json:"udp_associate_ok"`
|
||||||
|
UDPExchangeOK bool `json:"udp_exchange_ok"`
|
||||||
AuthMethod string `json:"auth_method,omitempty"`
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
RelayAddr string `json:"relay_addr,omitempty"`
|
RelayAddr string `json:"relay_addr,omitempty"`
|
||||||
|
DNSServer string `json:"dns_server,omitempty"`
|
||||||
|
DNSName string `json:"dns_name,omitempty"`
|
||||||
|
DNSRCode int `json:"dns_rcode,omitempty"`
|
||||||
|
RoundTripMS int64 `json:"round_trip_ms,omitempty"`
|
||||||
Diagnosis string `json:"diagnosis,omitempty"`
|
Diagnosis string `json:"diagnosis,omitempty"`
|
||||||
Hint string `json:"hint,omitempty"`
|
Hint string `json:"hint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultProbeDNSServer = "1.1.1.1:53"
|
||||||
|
defaultProbeDNSName = "example.com"
|
||||||
|
)
|
||||||
|
|
||||||
func ProbeSOCKS5(
|
func ProbeSOCKS5(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
address string,
|
address string,
|
||||||
username string,
|
username string,
|
||||||
password string,
|
password string,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
|
) (ProbeResult, error) {
|
||||||
|
return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real
|
||||||
|
// UDP DNS round trip through the returned relay. Keeping the target injectable
|
||||||
|
// makes the negative paths deterministic in tests without weakening the
|
||||||
|
// production probe.
|
||||||
|
func probeSOCKS5(
|
||||||
|
ctx context.Context,
|
||||||
|
address string,
|
||||||
|
username string,
|
||||||
|
password string,
|
||||||
|
timeout time.Duration,
|
||||||
|
dnsServer string,
|
||||||
|
dnsName string,
|
||||||
) (ProbeResult, error) {
|
) (ProbeResult, error) {
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||||
@@ -122,11 +150,228 @@ func ProbeSOCKS5(
|
|||||||
port := int(portBytes[0])<<8 | int(portBytes[1])
|
port := int(portBytes[0])<<8 | int(portBytes[1])
|
||||||
result.UDPAssociateOK = true
|
result.UDPAssociateOK = true
|
||||||
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||||
|
result.DNSServer = dnsServer
|
||||||
|
result.DNSName = dnsName
|
||||||
|
|
||||||
|
if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil {
|
||||||
|
if result.Diagnosis == "" {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
}
|
||||||
|
if result.Hint == "" {
|
||||||
|
result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。")
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
result.Diagnosis = "ready"
|
result.Diagnosis = "ready"
|
||||||
result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。")
|
result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。")
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func probeUDPExchange(
|
||||||
|
ctx context.Context,
|
||||||
|
control net.Conn,
|
||||||
|
result *ProbeResult,
|
||||||
|
relayHost string,
|
||||||
|
relayPort int,
|
||||||
|
dnsServer string,
|
||||||
|
dnsName string,
|
||||||
|
timeout time.Duration,
|
||||||
|
) error {
|
||||||
|
if result == nil {
|
||||||
|
return errors.New("proxy: probe result is nil")
|
||||||
|
}
|
||||||
|
dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer))
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_target"
|
||||||
|
return fmt.Errorf("proxy: resolve UDP probe target: %w", err)
|
||||||
|
}
|
||||||
|
relayHost = strings.TrimSpace(relayHost)
|
||||||
|
if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() {
|
||||||
|
remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String())
|
||||||
|
if splitErr != nil {
|
||||||
|
result.Diagnosis = "invalid_udp_relay"
|
||||||
|
return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr)
|
||||||
|
}
|
||||||
|
relayHost = remoteHost
|
||||||
|
}
|
||||||
|
relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort)))
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_udp_relay"
|
||||||
|
return fmt.Errorf("proxy: resolve UDP relay: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localNetwork := "udp4"
|
||||||
|
if relayAddress.IP != nil && relayAddress.IP.To4() == nil {
|
||||||
|
localNetwork = "udp6"
|
||||||
|
}
|
||||||
|
udpConnection, err := net.ListenUDP(localNetwork, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_socket_failed"
|
||||||
|
return fmt.Errorf("proxy: open UDP probe socket: %w", err)
|
||||||
|
}
|
||||||
|
defer udpConnection.Close()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||||
|
deadline = contextDeadline
|
||||||
|
}
|
||||||
|
if err := udpConnection.SetDeadline(deadline); err != nil {
|
||||||
|
return fmt.Errorf("proxy: set UDP probe deadline: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query, queryID, err := buildDNSQuery(dnsName)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_name"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
datagram, err := buildSOCKSUDPDatagram(dnsAddress, query)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "invalid_dns_target"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
startedAt := time.Now()
|
||||||
|
if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil {
|
||||||
|
result.Diagnosis = "udp_send_failed"
|
||||||
|
return fmt.Errorf("proxy: send UDP DNS probe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBuffer := make([]byte, 64*1024)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err)
|
||||||
|
}
|
||||||
|
count, sender, err := udpConnection.ReadFromUDP(responseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_no_roundtrip"
|
||||||
|
return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err)
|
||||||
|
}
|
||||||
|
if !sameUDPAddress(sender, relayAddress) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload, err := parseSOCKSUDPDatagram(responseBuffer[:count])
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "udp_invalid_response"
|
||||||
|
return fmt.Errorf("proxy: parse UDP relay response: %w", err)
|
||||||
|
}
|
||||||
|
rcode, err := validateDNSResponse(payload, queryID)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnosis = "dns_invalid_response"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.UDPExchangeOK = true
|
||||||
|
result.DNSRCode = rcode
|
||||||
|
result.RoundTripMS = time.Since(startedAt).Milliseconds()
|
||||||
|
if result.RoundTripMS < 1 {
|
||||||
|
result.RoundTripMS = 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDNSQuery(name string) ([]byte, uint16, error) {
|
||||||
|
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||||
|
if name == "" || len(name) > 253 {
|
||||||
|
return nil, 0, errors.New("proxy: UDP probe DNS name is invalid")
|
||||||
|
}
|
||||||
|
var idBytes [2]byte
|
||||||
|
if _, err := rand.Read(idBytes[:]); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err)
|
||||||
|
}
|
||||||
|
queryID := binary.BigEndian.Uint16(idBytes[:])
|
||||||
|
query := make([]byte, 12, 12+len(name)+6)
|
||||||
|
binary.BigEndian.PutUint16(query[0:2], queryID)
|
||||||
|
binary.BigEndian.PutUint16(query[2:4], 0x0100)
|
||||||
|
binary.BigEndian.PutUint16(query[4:6], 1)
|
||||||
|
for _, label := range strings.Split(name, ".") {
|
||||||
|
if label == "" || len(label) > 63 {
|
||||||
|
return nil, 0, errors.New("proxy: UDP probe DNS label is invalid")
|
||||||
|
}
|
||||||
|
query = append(query, byte(len(label)))
|
||||||
|
query = append(query, label...)
|
||||||
|
}
|
||||||
|
query = append(query, 0, 0, 1, 0, 1)
|
||||||
|
return query, queryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||||
|
if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 {
|
||||||
|
return nil, errors.New("proxy: UDP target is invalid")
|
||||||
|
}
|
||||||
|
packet := []byte{0, 0, 0}
|
||||||
|
if ipv4 := target.IP.To4(); ipv4 != nil {
|
||||||
|
packet = append(packet, 1)
|
||||||
|
packet = append(packet, ipv4...)
|
||||||
|
} else if ipv6 := target.IP.To16(); ipv6 != nil {
|
||||||
|
packet = append(packet, 4)
|
||||||
|
packet = append(packet, ipv6...)
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("proxy: UDP target address family is invalid")
|
||||||
|
}
|
||||||
|
packet = append(packet, byte(target.Port>>8), byte(target.Port))
|
||||||
|
packet = append(packet, payload...)
|
||||||
|
return packet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) {
|
||||||
|
if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 {
|
||||||
|
return nil, errors.New("invalid SOCKS5 UDP header")
|
||||||
|
}
|
||||||
|
if packet[2] != 0 {
|
||||||
|
return nil, errors.New("fragmented SOCKS5 UDP response is unsupported")
|
||||||
|
}
|
||||||
|
offset := 4
|
||||||
|
switch packet[3] {
|
||||||
|
case 1:
|
||||||
|
offset += net.IPv4len
|
||||||
|
case 3:
|
||||||
|
if len(packet) <= offset {
|
||||||
|
return nil, errors.New("truncated SOCKS5 UDP domain")
|
||||||
|
}
|
||||||
|
offset += 1 + int(packet[offset])
|
||||||
|
case 4:
|
||||||
|
offset += net.IPv6len
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported SOCKS5 UDP address type")
|
||||||
|
}
|
||||||
|
if offset+2 > len(packet) {
|
||||||
|
return nil, errors.New("truncated SOCKS5 UDP endpoint")
|
||||||
|
}
|
||||||
|
offset += 2
|
||||||
|
if offset >= len(packet) {
|
||||||
|
return nil, errors.New("empty SOCKS5 UDP payload")
|
||||||
|
}
|
||||||
|
return packet[offset:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDNSResponse(payload []byte, queryID uint16) (int, error) {
|
||||||
|
if len(payload) < 12 {
|
||||||
|
return 0, errors.New("proxy: DNS response is truncated")
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint16(payload[0:2]) != queryID {
|
||||||
|
return 0, errors.New("proxy: DNS response ID does not match")
|
||||||
|
}
|
||||||
|
flags := binary.BigEndian.Uint16(payload[2:4])
|
||||||
|
if flags&0x8000 == 0 {
|
||||||
|
return 0, errors.New("proxy: DNS response is not a response")
|
||||||
|
}
|
||||||
|
rcode := int(flags & 0x000f)
|
||||||
|
if rcode != 0 {
|
||||||
|
return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode)
|
||||||
|
}
|
||||||
|
return rcode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameUDPAddress(left, right *net.UDPAddr) bool {
|
||||||
|
if left == nil || right == nil || left.Port != right.Port {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left.IP == nil || right.IP == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return left.IP.Equal(right.IP)
|
||||||
|
}
|
||||||
|
|
||||||
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
||||||
switch addressType {
|
switch addressType {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) {
|
||||||
|
address, stop := startProbeSOCKS5Server(t, false)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
result, err := probeSOCKS5(
|
||||||
|
context.Background(),
|
||||||
|
address,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
250*time.Millisecond,
|
||||||
|
"192.0.2.53:53",
|
||||||
|
"example.test",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data")
|
||||||
|
}
|
||||||
|
if !result.UDPAssociateOK {
|
||||||
|
t.Fatal("UDP ASSOCIATE should have succeeded")
|
||||||
|
}
|
||||||
|
if result.UDPExchangeOK {
|
||||||
|
t.Fatal("UDP exchange should not be reported as successful")
|
||||||
|
}
|
||||||
|
if result.Diagnosis != "udp_no_roundtrip" {
|
||||||
|
t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) {
|
||||||
|
address, stop := startProbeSOCKS5Server(t, true)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
result, err := probeSOCKS5(
|
||||||
|
context.Background(),
|
||||||
|
address,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
time.Second,
|
||||||
|
"192.0.2.53:53",
|
||||||
|
"example.test",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Probe returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK {
|
||||||
|
t.Fatalf("Probe evidence incomplete: %+v", result)
|
||||||
|
}
|
||||||
|
if result.Diagnosis != "ready" {
|
||||||
|
t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis)
|
||||||
|
}
|
||||||
|
if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" {
|
||||||
|
t.Fatalf("Unexpected DNS evidence: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListenUDP: %v", err)
|
||||||
|
}
|
||||||
|
tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
udpConnection.Close()
|
||||||
|
t.Fatalf("Listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if echoDNS {
|
||||||
|
go func() {
|
||||||
|
buffer := make([]byte, 2048)
|
||||||
|
count, sender, readErr := udpConnection.ReadFromUDP(buffer)
|
||||||
|
if readErr != nil || count < 22 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The test target is IPv4, so the SOCKS5 UDP header is ten bytes.
|
||||||
|
buffer[12] = 0x81
|
||||||
|
buffer[13] = 0x80
|
||||||
|
_, _ = udpConnection.WriteToUDP(buffer[:count], sender)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
connection, acceptErr := tcpListener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
greeting := make([]byte, 3)
|
||||||
|
if _, readErr := io.ReadFull(connection, greeting); readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
associate := make([]byte, 10)
|
||||||
|
if _, readErr := io.ReadFull(connection, associate); readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)}
|
||||||
|
if _, writeErr := connection.Write(response); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, connection)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return tcpListener.Addr().String(), func() {
|
||||||
|
_ = tcpListener.Close()
|
||||||
|
_ = udpConnection.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/modem"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -44,3 +53,62 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The AT terminal must present ERROR / +CME ERROR as a normal response, not as
|
||||||
|
// a 502. Before the CommandError branch was restored, every unsupported or
|
||||||
|
// SIM-less command was folded into "the device operation failed", hiding the
|
||||||
|
// real reason from the user.
|
||||||
|
func TestHandleATSurfacesCommandErrorAsResponse(t *testing.T) {
|
||||||
|
controller := fakeDeviceController{
|
||||||
|
entry: device.Device{ID: "dev1"},
|
||||||
|
atHandler: func(command string) (modem.Response, error) {
|
||||||
|
return modem.Response{}, &modem.CommandError{
|
||||||
|
Command: command,
|
||||||
|
Final: "+CME ERROR: 10",
|
||||||
|
Lines: []string{"+CME ERROR: 10"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/devices/dev1/actions/at",
|
||||||
|
strings.NewReader(`{"cmd":"AT+CPIN?","timeout_ms":5000}`),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !server.handleAT(recorder, request, "dev1") {
|
||||||
|
t.Fatal("handleAT returned false")
|
||||||
|
}
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body=%s)", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
data := decodeData(t, recorder)
|
||||||
|
response, _ := data["response"].(string)
|
||||||
|
if !strings.Contains(response, "+CME ERROR: 10") {
|
||||||
|
t.Fatalf("response = %q, want +CME ERROR text", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleATMapsNonCommandErrorTo502(t *testing.T) {
|
||||||
|
controller := fakeDeviceController{
|
||||||
|
entry: device.Device{ID: "dev1"},
|
||||||
|
atErr: errors.New("transport wedged"),
|
||||||
|
}
|
||||||
|
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/devices/dev1/actions/at",
|
||||||
|
strings.NewReader(`{"cmd":"AT+CSQ"}`),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !server.handleAT(recorder, request, "dev1") {
|
||||||
|
t.Fatal("handleAT returned false")
|
||||||
|
}
|
||||||
|
if recorder.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want 502", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.store.Ready(ctx); err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMetrics exposes only process-level, non-identifying Prometheus data.
|
||||||
|
// Device IDs, SIM identities, phone numbers and proxy information never enter
|
||||||
|
// this unauthenticated endpoint.
|
||||||
|
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireMethod(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ready := 0
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||||
|
if s.store.Ready(ctx) == nil {
|
||||||
|
ready = 1
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
fmt.Fprint(w, "# HELP vocat_up Whether the process is running.\n# TYPE vocat_up gauge\nvocat_up 1\n")
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_ready Whether the database is ready.\n# TYPE vocat_ready gauge\nvocat_ready %d\n", ready)
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_uptime_seconds Process uptime.\n# TYPE vocat_uptime_seconds counter\nvocat_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())
|
||||||
|
fmt.Fprintf(w, "# HELP vocat_go_goroutines Current Go goroutines.\n# TYPE vocat_go_goroutines gauge\nvocat_go_goroutines %d\n", runtime.NumGoroutine())
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOperationalHealthEndpointsAreAnonymousAndNonIdentifying(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
contentType string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{path: "/healthz", contentType: "application/json", contains: `"status":"ok"`},
|
||||||
|
{path: "/readyz", contentType: "application/json", contains: `"status":"ready"`},
|
||||||
|
{path: "/metrics", contentType: "text/plain", contains: "vocat_ready 1"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.path, func(t *testing.T) {
|
||||||
|
response, err := app.client.Get(app.server.URL + test.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Header.Get("Content-Type"), test.contentType) {
|
||||||
|
t.Fatalf("Content-Type = %q", response.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), test.contains) {
|
||||||
|
t.Fatalf("body = %q, want %q", body, test.contains)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"imsi", "iccid", "msisdn", "proxy", "device_id"} {
|
||||||
|
if strings.Contains(strings.ToLower(string(body)), forbidden) {
|
||||||
|
t.Fatalf("body exposes forbidden label %q: %s", forbidden, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperationalHealthEndpointsRejectPOST(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
for _, path := range []string{"/healthz", "/readyz", "/metrics"} {
|
||||||
|
request, err := http.NewRequest(http.MethodPost, app.server.URL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response, err := app.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("%s status = %d, want %d", path, response.StatusCode, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLongSMSReassemblySurvivesServiceRestart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||||
|
const (
|
||||||
|
deviceID = "dajiang"
|
||||||
|
imei = "867394042309830"
|
||||||
|
peer = "+447700900123"
|
||||||
|
)
|
||||||
|
messageID := StableConcatMessageID("ims", imei, deviceID, peer, 27, 2)
|
||||||
|
|
||||||
|
database, err := Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSaveDevice(t, database, deviceID, "大疆")
|
||||||
|
first, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第一段:安全提醒,",
|
||||||
|
Timestamp: time.Unix(1_700_000_000, 0).UTC(), Status: "received", Source: "ims",
|
||||||
|
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
|
||||||
|
t.Fatal("partial message must not be ready before restart")
|
||||||
|
}
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err = Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
second, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||||
|
Timestamp: time.Unix(1_700_000_030, 0).UTC(), Status: "received", Source: "ims",
|
||||||
|
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.Body != "第一段:安全提醒,第二段:请通过官方渠道核实。" ||
|
||||||
|
!ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
|
||||||
|
t.Fatalf("reassembled message after restart = %#v", second)
|
||||||
|
}
|
||||||
|
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: deviceID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 || messages[0].ID != second.ID {
|
||||||
|
t.Fatalf("stored messages after restart = %#v, want one merged row", messages)
|
||||||
|
}
|
||||||
|
redelivered, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||||
|
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||||
|
Status: "received", Source: "ims", PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if redelivered.ID != second.ID || redelivered.Body != second.Body {
|
||||||
|
t.Fatalf("redelivery duplicated or changed message: %#v", redelivered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartDeliveryReportsSurviveServiceRestart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||||
|
database, err := Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSaveDevice(t, database, "dajiang", "大疆")
|
||||||
|
extra := json.RawMessage(`{"transport":"ims","part_results":[{"reference":51},{"reference":52}]}`)
|
||||||
|
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: "ims-submit-restart", DeviceID: "dajiang", IMSI: "23433",
|
||||||
|
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
|
||||||
|
Status: "accepted_by_ims", Source: "ims", PartsTotal: 2,
|
||||||
|
DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err = Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||||
|
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||||
|
MessageReference: 51, StatusCode: 0, DeliveryState: "delivered",
|
||||||
|
})
|
||||||
|
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
|
||||||
|
t.Fatalf("first report after restart = (%#v, %v)", first, err)
|
||||||
|
}
|
||||||
|
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||||
|
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||||
|
MessageReference: 52, StatusCode: 0, DeliveryState: "delivered",
|
||||||
|
})
|
||||||
|
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
|
||||||
|
t.Fatalf("second report after restart = (%#v, %v)", second, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectSystemdUnitUsesExplicitOverride(t *testing.T) {
|
||||||
|
t.Setenv("VOCAT_SYSTEMD_UNIT", "vocat-test.service")
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if got := detectSystemdUnit(logger); got != "vocat-test.service" {
|
||||||
|
t.Fatalf("detectSystemdUnit() = %q, want vocat-test.service", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdUnitFromCgroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "cgroup v2", data: "0::/system.slice/vocat-test.service\n", want: "vocat-test.service"},
|
||||||
|
{name: "legacy", data: "1:name=systemd:/system.slice/[email protected]\n", want: "[email protected]"},
|
||||||
|
{name: "no service", data: "0::/user.slice/user-1000.slice/session-1.scope\n", want: ""},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := systemdUnitFromCgroup(test.data); got != test.want {
|
||||||
|
t.Fatalf("systemdUnitFromCgroup() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSystemdUnitRejectsArgumentsAndPaths(t *testing.T) {
|
||||||
|
for _, value := range []string{"vocat", "../vocat.service", "vocat.service --now", "vocat.service/other"} {
|
||||||
|
if validSystemdUnit.MatchString(value) {
|
||||||
|
t.Fatalf("validSystemdUnit unexpectedly accepted %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -320,17 +322,63 @@ func RestartService(logger *slog.Logger) error {
|
|||||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||||
}
|
}
|
||||||
|
unit := detectSystemdUnit(logger)
|
||||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||||
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||||
return fmt.Errorf("systemctl restart vocat: %w", err)
|
return fmt.Errorf("systemctl restart %s: %w", unit, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var validSystemdUnit = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`)
|
||||||
|
|
||||||
|
func detectSystemdUnit(logger *slog.Logger) string {
|
||||||
|
if configured := strings.TrimSpace(os.Getenv("VOCAT_SYSTEMD_UNIT")); validSystemdUnit.MatchString(configured) {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||||
|
if unit := systemdUnitFromCgroup(string(data)); unit != "" {
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Some cgroup namespaces hide the unit name. Query loaded services and
|
||||||
|
// identify the unit whose MainPID is this process before falling back.
|
||||||
|
list := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain")
|
||||||
|
if output, err := list.Output(); err == nil {
|
||||||
|
pid := strconv.Itoa(os.Getpid())
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || !validSystemdUnit.MatchString(fields[0]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
show := exec.Command("systemctl", "show", fields[0], "--property=MainPID", "--value")
|
||||||
|
if value, showErr := show.Output(); showErr == nil && strings.TrimSpace(string(value)) == pid {
|
||||||
|
return fields[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("could not identify the current systemd unit; using vocat.service", "hint", "set VOCAT_SYSTEMD_UNIT for a custom unit")
|
||||||
|
}
|
||||||
|
return "vocat.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemdUnitFromCgroup(data string) string {
|
||||||
|
for _, line := range strings.Split(data, "\n") {
|
||||||
|
for _, part := range strings.Split(line, "/") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if validSystemdUnit.MatchString(part) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// resolveDefaultTarget returns the conventional install path when present,
|
// resolveDefaultTarget returns the conventional install path when present,
|
||||||
// falling back to the running executable. This lets `vocat update` "just work"
|
// falling back to the running executable. This lets `vocat update` "just work"
|
||||||
// on the standard systemd host without flags.
|
// on the standard systemd host without flags.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -375,7 +375,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
return fmt.Errorf("unexpected start line %q", startLine)
|
return fmt.Errorf("unexpected start line %q", startLine)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{
|
for _, forbidden := range []string{
|
||||||
"p-access-network-info",
|
|
||||||
"p-visited-network-id",
|
"p-visited-network-id",
|
||||||
"p-preferred-identity",
|
"p-preferred-identity",
|
||||||
} {
|
} {
|
||||||
@@ -387,6 +386,13 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
|
||||||
|
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||||
|
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||||
|
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
||||||
|
}
|
||||||
if step == 0 {
|
if step == 0 {
|
||||||
if headers["authorization"] != "" {
|
if headers["authorization"] != "" {
|
||||||
return errors.New("initial REGISTER unexpectedly authenticated")
|
return errors.New("initial REGISTER unexpectedly authenticated")
|
||||||
|
|||||||
@@ -534,25 +534,7 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
|||||||
for _, protocol := range flow.protocols {
|
for _, protocol := range flow.protocols {
|
||||||
operations = append(operations, xfrmOperation{
|
operations = append(operations, xfrmOperation{
|
||||||
description: flow.description + " " + protocol + " policy",
|
description: flow.description + " " + protocol + " policy",
|
||||||
arguments: []string{
|
arguments: xfrmPolicyArgs(flow, protocol, false),
|
||||||
flow.family,
|
|
||||||
"xfrm", "policy", "add",
|
|
||||||
"src", flow.sourcePrefix,
|
|
||||||
"dst", flow.destinationPrefix,
|
|
||||||
"proto", protocol,
|
|
||||||
"sport", strconv.Itoa(flow.sourcePort),
|
|
||||||
"dport", strconv.Itoa(flow.destinationPort),
|
|
||||||
"dir", flow.direction,
|
|
||||||
"priority", "100",
|
|
||||||
"tmpl",
|
|
||||||
"src", flow.templateSource.String(),
|
|
||||||
"dst", flow.templateDestination.String(),
|
|
||||||
"proto", "esp",
|
|
||||||
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
|
||||||
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
|
||||||
"mode", "transport",
|
|
||||||
"level", "required",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,16 +550,7 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
|||||||
protocol := flow.protocols[protocolIndex]
|
protocol := flow.protocols[protocolIndex]
|
||||||
operations = append(operations, xfrmOperation{
|
operations = append(operations, xfrmOperation{
|
||||||
description: "delete " + flow.description + " " + protocol + " policy",
|
description: "delete " + flow.description + " " + protocol + " policy",
|
||||||
arguments: []string{
|
arguments: xfrmPolicyArgs(flow, protocol, true),
|
||||||
flow.family,
|
|
||||||
"xfrm", "policy", "delete",
|
|
||||||
"src", flow.sourcePrefix,
|
|
||||||
"dst", flow.destinationPrefix,
|
|
||||||
"proto", protocol,
|
|
||||||
"sport", strconv.Itoa(flow.sourcePort),
|
|
||||||
"dport", strconv.Itoa(flow.destinationPort),
|
|
||||||
"dir", flow.direction,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,6 +579,44 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
|||||||
return operations
|
return operations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func xfrmPolicyArgs(flow xfrmFlow, protocol string, delete bool) []string {
|
||||||
|
args := []string{
|
||||||
|
flow.family,
|
||||||
|
"xfrm", "policy",
|
||||||
|
}
|
||||||
|
if delete {
|
||||||
|
args = append(args, "delete")
|
||||||
|
} else {
|
||||||
|
args = append(args, "add")
|
||||||
|
}
|
||||||
|
args = append(args,
|
||||||
|
"src", flow.sourcePrefix,
|
||||||
|
"dst", flow.destinationPrefix,
|
||||||
|
"proto", protocol,
|
||||||
|
)
|
||||||
|
if flow.sourcePort > 0 {
|
||||||
|
args = append(args, "sport", strconv.Itoa(flow.sourcePort))
|
||||||
|
}
|
||||||
|
if flow.destinationPort > 0 {
|
||||||
|
args = append(args, "dport", strconv.Itoa(flow.destinationPort))
|
||||||
|
}
|
||||||
|
args = append(args, "dir", flow.direction)
|
||||||
|
if delete {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
return append(args,
|
||||||
|
"priority", "100",
|
||||||
|
"tmpl",
|
||||||
|
"src", flow.templateSource.String(),
|
||||||
|
"dst", flow.templateDestination.String(),
|
||||||
|
"proto", "esp",
|
||||||
|
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
||||||
|
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
||||||
|
"mode", "transport",
|
||||||
|
"level", "required",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type xfrmFlow struct {
|
type xfrmFlow struct {
|
||||||
description string
|
description string
|
||||||
family string
|
family string
|
||||||
@@ -650,7 +661,7 @@ func xfrmFlows(config IPSecSAConfig) []xfrmFlow {
|
|||||||
{
|
{
|
||||||
description: "P-CSCF-client to UE-server", family: family,
|
description: "P-CSCF-client to UE-server", family: family,
|
||||||
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
||||||
sourcePort: config.PCSCFClientPort, destinationPort: config.UEServerPort,
|
sourcePort: 0, destinationPort: config.UEServerPort,
|
||||||
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
||||||
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
||||||
protocols: []string{"tcp", "udp"},
|
protocols: []string{"tcp", "udp"},
|
||||||
@@ -916,9 +927,7 @@ func (session *Session) validProtectedUDPSource(remote *net.UDPAddr) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expectedIP := addressIP(session.conn.RemoteAddr())
|
expectedIP := addressIP(session.conn.RemoteAddr())
|
||||||
return expectedIP != nil &&
|
return expectedIP != nil && expectedIP.Equal(remote.IP)
|
||||||
expectedIP.Equal(remote.IP) &&
|
|
||||||
remote.Port == session.securityAgreement.selected.portClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) effectiveSecurityMode() string {
|
func (session *Session) effectiveSecurityMode() string {
|
||||||
|
|||||||
@@ -531,8 +531,8 @@ func serveProtectedRegistrar(
|
|||||||
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
||||||
strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
!strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
||||||
return result, errors.New("registration transaction advertised or sent MESSAGE")
|
return result, errors.New("registration transaction did not advertise MESSAGE correctly")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := protectedConnection.Write(testResponse(
|
if _, err := protectedConnection.Write(testResponse(
|
||||||
|
|||||||
@@ -287,14 +287,18 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
|||||||
"tcp 40666 50600 out": false,
|
"tcp 40666 50600 out": false,
|
||||||
"udp 40666 50600 out": false,
|
"udp 40666 50600 out": false,
|
||||||
"tcp 50600 40666 in": false,
|
"tcp 50600 40666 in": false,
|
||||||
"tcp 50601 55610 in": false,
|
"tcp * 55610 in": false,
|
||||||
"udp 50601 55610 in": false,
|
"udp * 55610 in": false,
|
||||||
"tcp 55610 50601 out": false,
|
"tcp 55610 50601 out": false,
|
||||||
}
|
}
|
||||||
for _, operation := range install[4:] {
|
for _, operation := range install[4:] {
|
||||||
|
sourcePort := "*"
|
||||||
|
if value, ok := optionalArgumentAfter(operation.arguments, "sport"); ok {
|
||||||
|
sourcePort = value
|
||||||
|
}
|
||||||
key := strings.Join([]string{
|
key := strings.Join([]string{
|
||||||
argumentAfter(t, operation.arguments, "proto"),
|
argumentAfter(t, operation.arguments, "proto"),
|
||||||
argumentAfter(t, operation.arguments, "sport"),
|
sourcePort,
|
||||||
argumentAfter(t, operation.arguments, "dport"),
|
argumentAfter(t, operation.arguments, "dport"),
|
||||||
argumentAfter(t, operation.arguments, "dir"),
|
argumentAfter(t, operation.arguments, "dir"),
|
||||||
}, " ")
|
}, " ")
|
||||||
@@ -397,6 +401,15 @@ func argumentAfter(t *testing.T, arguments []string, name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalArgumentAfter(arguments []string, name string) (string, bool) {
|
||||||
|
for index := 0; index+1 < len(arguments); index++ {
|
||||||
|
if arguments[index] == name {
|
||||||
|
return arguments[index+1], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func containsArguments(arguments []string, sequence ...string) bool {
|
func containsArguments(arguments []string, sequence ...string) bool {
|
||||||
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -91,11 +91,14 @@ func (session *Session) startRuntimeReceivers() error {
|
|||||||
|
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.readMainConnection()
|
go session.readMainConnection()
|
||||||
if session.securityActive && session.transport == "tcp" && session.protectedTCP != nil {
|
// Vodafone UK (and others) deliver MT SMS as SIP MESSAGE to the
|
||||||
|
// ipsec-3gpp UE server port over UDP even when REGISTER used TCP.
|
||||||
|
// Always read both sockets when they were reserved.
|
||||||
|
if session.securityActive && session.protectedTCP != nil {
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.acceptProtectedTCP()
|
go session.acceptProtectedTCP()
|
||||||
}
|
}
|
||||||
if session.securityActive && session.transport == "udp" && session.protectedUDP != nil {
|
if session.securityActive && session.protectedUDP != nil {
|
||||||
session.receiveDone.Add(1)
|
session.receiveDone.Add(1)
|
||||||
go session.readProtectedUDP()
|
go session.readProtectedUDP()
|
||||||
}
|
}
|
||||||
@@ -140,6 +143,8 @@ func (session *Session) acceptProtectedTCP() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound TCP rejected", nil,
|
||||||
|
"stage", "source_filter", "remote", connection.RemoteAddr().String())
|
||||||
_ = connection.Close()
|
_ = connection.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -186,6 +191,8 @@ func (session *Session) readProtectedUDP() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !session.validProtectedUDPSource(remote) {
|
if !session.validProtectedUDPSource(remote) {
|
||||||
|
session.logInboundSMS(slog.LevelWarn, "IMS inbound UDP rejected", nil,
|
||||||
|
"stage", "source_filter", "remote", remote.String(), "packet_bytes", count)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
packet, err := parseSIPPacket(buffer[:count])
|
packet, err := parseSIPPacket(buffer[:count])
|
||||||
@@ -208,8 +215,9 @@ func (session *Session) validProtectedTCPSource(address net.Addr) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expected := addressIP(session.conn.RemoteAddr())
|
expected := addressIP(session.conn.RemoteAddr())
|
||||||
return expected != nil && expected.Equal(remote.IP) &&
|
// Require P-CSCF IP. Do not require port-c (50601): some cores originate
|
||||||
remote.Port == session.securityAgreement.selected.portClient
|
// MESSAGE from an ephemeral port on the same P-CSCF.
|
||||||
|
return expected != nil && expected.Equal(remote.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) {
|
func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) {
|
||||||
|
|||||||
@@ -40,23 +40,35 @@ function ProbeResultPanel({ probe }: { probe: UpstreamProbeResult }) {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const reachable = !!probe.reachable;
|
const reachable = !!probe.reachable;
|
||||||
const handshakeOk = !!probe.handshakeOk;
|
const handshakeOk = !!probe.handshakeOk;
|
||||||
const udpOk = !!probe.udpAssociateOk;
|
const associateOk = !!probe.udpAssociateOk;
|
||||||
|
const udpOk = !!probe.udpExchangeOk;
|
||||||
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
||||||
const udpState: ProbeState = !handshakeOk ? "pending" : udpOk ? "ok" : "fail";
|
const associateState: ProbeState = !handshakeOk ? "pending" : associateOk ? "ok" : "fail";
|
||||||
|
const udpState: ProbeState = !associateOk ? "pending" : udpOk ? "ok" : "fail";
|
||||||
return (
|
return (
|
||||||
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
||||||
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
||||||
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
||||||
<ProbeRow
|
<ProbeRow
|
||||||
state={udpState}
|
state={associateState}
|
||||||
label={t("UDP Associate(VoWiFi 依赖)")}
|
label={t("UDP Associate(VoWiFi 依赖)")}
|
||||||
detail={udpState === "pending" ? undefined : udpOk ? t("支持") : t("不支持")}
|
detail={associateState === "pending" ? undefined : associateOk ? t("已建立") : t("不支持")}
|
||||||
|
/>
|
||||||
|
<ProbeRow
|
||||||
|
state={udpState}
|
||||||
|
label={t("真实 UDP DNS 往返")}
|
||||||
|
detail={udpState === "pending" ? undefined : udpOk ? `${probe.roundTripMs || 0} ms` : t("无返回")}
|
||||||
/>
|
/>
|
||||||
{probe.relayAddr ? (
|
{probe.relayAddr ? (
|
||||||
<div className="text-[11px] text-gray-400">
|
<div className="text-[11px] text-gray-400">
|
||||||
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{probe.dnsName && probe.dnsServer ? (
|
||||||
|
<div className="text-[11px] text-gray-400">
|
||||||
|
{t("UDP 测试:")}<span className="font-mono">{probe.dnsName} @ {probe.dnsServer}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
||||||
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +139,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
|
|||||||
</div>
|
</div>
|
||||||
{probe ? (
|
{probe ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SectionHeader tone={probe.udpAssociateOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
<SectionHeader tone={probe.udpExchangeOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||||
<ProbeResultPanel probe={probe} />
|
<ProbeResultPanel probe={probe} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ export interface UpstreamProbeResult {
|
|||||||
reachable?: boolean;
|
reachable?: boolean;
|
||||||
handshakeOk?: boolean;
|
handshakeOk?: boolean;
|
||||||
udpAssociateOk?: boolean;
|
udpAssociateOk?: boolean;
|
||||||
|
udpExchangeOk?: boolean;
|
||||||
authMethod?: string;
|
authMethod?: string;
|
||||||
relayAddr?: string;
|
relayAddr?: string;
|
||||||
|
dnsServer?: string;
|
||||||
|
dnsName?: string;
|
||||||
|
dnsRcode?: number;
|
||||||
|
roundTripMs?: number;
|
||||||
diagnosis?: string;
|
diagnosis?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user