mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-18 05:43:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb2ac45ce | ||
|
|
95299a0a39 | ||
|
|
1a4032d013 | ||
|
|
6ec950bfd2 | ||
|
|
04dd026e95 | ||
|
|
a2a72dfd30 | ||
|
|
67079c99b9 | ||
|
|
f949001480 | ||
|
|
fa8afb9571 | ||
|
|
bfda29193a | ||
|
|
ed64fd428a | ||
|
|
f84a1f99b1 | ||
|
|
e2177a6e9a | ||
|
|
115598757a | ||
|
|
297d2c1839 | ||
|
|
7ba30132f9 | ||
|
|
82494f519b | ||
|
|
5eee89a92a | ||
|
|
ae3a2a6eea | ||
|
|
505ee1eac0 |
@@ -0,0 +1,57 @@
|
||||
Copyright (c) 2013, Dustin L. Howett. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The views and conclusions contained in the software and documentation are
|
||||
those of the authors and should not be interpreted as representing official
|
||||
policies, either expressed or implied, of the FreeBSD Project.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Parts of this package were made available under the license covering the Go
|
||||
language and all attended core libraries. That license follows.
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -212,6 +212,10 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
||||
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
||||
|
||||
User-supplied Apple carrier bundles can be converted into reviewable,
|
||||
allow-listed carrier profiles with `vocat carrier import-ipcc`; see
|
||||
[docs/CARRIER_IPCC_IMPORT.md](docs/CARRIER_IPCC_IMPORT.md).
|
||||
|
||||
Administrator credentials are stored only in SQLite. Initialize an empty
|
||||
database once with `vocat bootstrap-admin`; environment variables and JSON
|
||||
configuration cannot set or overwrite the administrator username or password.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func runCarrier(args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: vocat carrier import-ipcc [flags] FILE.ipcc")
|
||||
}
|
||||
switch args[0] {
|
||||
case "import-ipcc":
|
||||
return runCarrierImportIPCC(args[1:], stdout)
|
||||
default:
|
||||
return fmt.Errorf("unknown carrier subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runCarrierImportIPCC(args []string, stdout io.Writer) error {
|
||||
flags := flag.NewFlagSet("carrier import-ipcc", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
var bundle string
|
||||
var profileID string
|
||||
var profileDir string
|
||||
var install bool
|
||||
var documentOnly bool
|
||||
flags.StringVar(&bundle, "bundle", "", "bundle name when an IPCC contains more than one carrier bundle")
|
||||
flags.StringVar(&profileID, "id", "", "override the generated carrier profile ID")
|
||||
flags.StringVar(&profileDir, "profile-dir", "", "installation directory (default: next to the VoCat database)")
|
||||
flags.BoolVar(&install, "install", false, "atomically install the reviewed generated profile")
|
||||
flags.BoolVar(&documentOnly, "document-only", false, "print only the generated carrier profile document")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 1 {
|
||||
return errors.New("usage: vocat carrier import-ipcc [--bundle NAME] [--id ID] [--document-only] [--install] [--profile-dir DIR] FILE.ipcc")
|
||||
}
|
||||
if documentOnly && install {
|
||||
return errors.New("--document-only and --install cannot be used together")
|
||||
}
|
||||
if strings.TrimSpace(profileDir) != "" && !install {
|
||||
return errors.New("--profile-dir requires --install")
|
||||
}
|
||||
result, err := vowifi.ImportCarrierIPCC(flags.Arg(0), vowifi.IPCCImportOptions{
|
||||
Bundle: bundle,
|
||||
ProfileID: profileID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if documentOnly {
|
||||
_, err := stdout.Write(result.Document)
|
||||
return err
|
||||
}
|
||||
|
||||
installedPath := ""
|
||||
if install {
|
||||
profileDir = strings.TrimSpace(profileDir)
|
||||
if profileDir == "" {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration for carrier profile directory: %w", err)
|
||||
}
|
||||
profileDir = filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||
}
|
||||
installedPath, err = vowifi.InstallCarrierIPCCResult(result, profileDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absolute, absoluteErr := filepath.Abs(installedPath); absoluteErr == nil {
|
||||
installedPath = absolute
|
||||
}
|
||||
}
|
||||
output := struct {
|
||||
vowifi.IPCCImportResult
|
||||
InstalledPath string `json:"installed_path,omitempty"`
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}{
|
||||
IPCCImportResult: result,
|
||||
InstalledPath: installedPath,
|
||||
RestartRequired: installedPath != "",
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(output)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
func TestRunCarrierImportIPCCPreviewsAndInstallsExplicitly(t *testing.T) {
|
||||
archivePath := filepath.Join(t.TempDir(), "test.ipcc")
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(file)
|
||||
entry, err := archive.Create("Payload/Test.bundle/carrier.plist")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := plist.NewEncoder(&encoded).Encode(map[string]any{
|
||||
"CarrierName": "Test Carrier",
|
||||
"SupportedSIMs": []any{"99901"},
|
||||
"SupportedPLMNs": []any{"99901"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var preview bytes.Buffer
|
||||
if err := runCarrier([]string{"import-ipcc", "--document-only", archivePath}, &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(preview.Bytes(), &document); err != nil || document.Version != 1 {
|
||||
t.Fatalf("preview = %q, version=%d, error=%v", preview.String(), document.Version, err)
|
||||
}
|
||||
|
||||
installDir := t.TempDir()
|
||||
var output bytes.Buffer
|
||||
if err := runCarrier([]string{
|
||||
"import-ipcc", "--id", "cli-test", "--install", "--profile-dir", installDir, archivePath,
|
||||
}, &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var installed struct {
|
||||
InstalledPath string `json:"installed_path"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
}
|
||||
if err := json.Unmarshal(output.Bytes(), &installed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installed.RestartRequired || filepath.Base(installed.InstalledPath) != "cli-test.json" {
|
||||
t.Fatalf("install output = %s", output.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "cli-test.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,15 @@ Usage:
|
||||
vocat serve Run the server in the foreground (use from a TTY when
|
||||
vocat without arguments would enter the menu).
|
||||
vocat version Print the build version and exit.
|
||||
vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths.
|
||||
Use --repair-dji-qmi on Linux to safely wake a factory-ID
|
||||
DJI/Baiwang 2ca3:4006 QMI interface without changing NV.
|
||||
vocat carrier import-ipcc [flags] FILE.ipcc
|
||||
Convert an Apple carrier bundle into a reviewable VoCat
|
||||
profile. Preview is the default; --install writes it to
|
||||
carrier-profiles.d and takes effect after restart.
|
||||
Flags: --bundle NAME --id ID --document-only --install
|
||||
--profile-dir DIR.
|
||||
vocat update Check GitHub for a newer release and self-update.
|
||||
Flags:
|
||||
--check Only report whether an update is available.
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
+23
-17
@@ -77,6 +77,16 @@ func main() {
|
||||
logger.Error("update failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "doctor":
|
||||
if err := runDoctor(rest); err != nil {
|
||||
logger.Error("doctor failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "carrier":
|
||||
if err := runCarrier(rest, os.Stdout); err != nil {
|
||||
logger.Error("carrier command failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "menu":
|
||||
if err := runMenu(logger); err != nil {
|
||||
logger.Error("menu failed", "error", err)
|
||||
@@ -120,6 +130,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
carrierProfileDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d")
|
||||
if err := vowifi.LoadCarrierProfileDirectory(carrierProfileDir); err != nil {
|
||||
return fmt.Errorf("load installed carrier profiles: %w", err)
|
||||
}
|
||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +206,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}
|
||||
|
||||
cardReaders := pcsc.New()
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device manager: %w", err)
|
||||
}
|
||||
@@ -721,27 +735,19 @@ func newVoWiFiOrchestrator(
|
||||
if apn == "" {
|
||||
apn = "ims"
|
||||
}
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||
}
|
||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||
Logger: logger,
|
||||
// The userspace SWu data plane carries protected P-CSCF signalling over
|
||||
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
|
||||
Transport: "tcp",
|
||||
TransportByPLMN: map[string]string{
|
||||
"23410": "udp",
|
||||
"234010": "udp",
|
||||
},
|
||||
// Some UK SIM profiles leave EF_SMSP/AT+CSCA empty. Keep fallbacks scoped
|
||||
// to their HPLMN so an O2/giffgaff SIM can never inherit Vodafone's SMSC.
|
||||
SMSCenterByPLMN: map[string]string{
|
||||
"23410": "+447802000332",
|
||||
"234010": "+447802000332",
|
||||
"23415": "+447785016005",
|
||||
"234015": "+447785016005",
|
||||
},
|
||||
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||
// alternate transport only if no SIP response was observed.
|
||||
Transport: "tcp",
|
||||
AutoTransportFallback: true,
|
||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"transport": "ims",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/vowifi"
|
||||
"vocat/internal/vowifi/integration"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,10 @@ func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Contex
|
||||
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
|
||||
}
|
||||
|
||||
func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id string) (vowifi.SIMMetadata, error) {
|
||||
return mapper.Mapper.ReadSIMMetadata(ctx, id)
|
||||
}
|
||||
|
||||
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
||||
physical, err := mapper.physical(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -195,6 +195,25 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
||||
|
||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||
|
||||
## Apple IPCC 运营商规则导入
|
||||
|
||||
VoCat 可以离线解析用户提供的 `.ipcc`,将 Apple 的 XML/二进制 plist
|
||||
转换为可审查的运营商 Profile。默认只预览,不会修改配置:
|
||||
|
||||
```bash
|
||||
vocat carrier import-ipcc Carrier_iPhone.ipcc
|
||||
```
|
||||
|
||||
确认警告和匹配范围后,使用 `--install` 安装;重启 VoCat 后生效:
|
||||
|
||||
```bash
|
||||
vocat carrier import-ipcc --install Carrier_iPhone.ipcc
|
||||
```
|
||||
|
||||
导入器不会复制关闭证书验证、绕过运营商授权、APN 凭据、紧急呼叫或
|
||||
设备型号专属媒体参数。完整字段和冲突处理说明见
|
||||
[CARRIER_IPCC_IMPORT.md](CARRIER_IPCC_IMPORT.md)。
|
||||
|
||||
## Telegram 机器人
|
||||
|
||||
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
||||
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.43.0
|
||||
howett.net/plist v1.0.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg=
|
||||
github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -49,10 +50,13 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
|
||||
@@ -3,6 +3,7 @@ package device
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) {
|
||||
return country, len(country) == 2
|
||||
}
|
||||
|
||||
// MCCsByCountry returns the complete MCC grouping from the embedded carrier
|
||||
// database, keyed by ISO alpha-2 country/territory code. The returned map and
|
||||
// slices are new values and may be safely modified by callers.
|
||||
func MCCsByCountry() map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
for mcc, rawCountry := range globalCarrierDatabase.Countries {
|
||||
country := strings.ToUpper(strings.TrimSpace(rawCountry))
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
if len(country) != 2 || len(mcc) != 3 {
|
||||
continue
|
||||
}
|
||||
result[country] = append(result[country], mcc)
|
||||
}
|
||||
for country := range result {
|
||||
sort.Strings(result[country])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var globalCarrierDatabase = func() carrierDatabase {
|
||||
var database carrierDatabase
|
||||
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
const maxHardwareErrorDetail = 1024
|
||||
|
||||
var longHexPayload = regexp.MustCompile(`(?i)\b[0-9a-f]{48,}\b`)
|
||||
|
||||
// HardwareErrorDetail returns a diagnostic error suitable for persistent and
|
||||
// browser-visible logs. AT payloads can contain APDU authentication material,
|
||||
// SMS data, or APN credentials, so CommandError values retain only the command
|
||||
// name and modem final result. Very long hexadecimal payloads from wrapped
|
||||
// protocol errors are removed as a second line of defence.
|
||||
func HardwareErrorDetail(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
detail := redactCommandErrors(err.Error(), err)
|
||||
detail = longHexPayload.ReplaceAllString(detail, "[redacted hex payload]")
|
||||
detail = strings.Map(func(character rune) rune {
|
||||
if unicode.IsControl(character) && character != '\t' && character != '\n' {
|
||||
return ' '
|
||||
}
|
||||
return character
|
||||
}, strings.TrimSpace(detail))
|
||||
runes := []rune(detail)
|
||||
if len(runes) > maxHardwareErrorDetail {
|
||||
detail = string(runes[:maxHardwareErrorDetail]) + "..."
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func redactCommandErrors(detail string, err error) string {
|
||||
if commandErr, ok := err.(*modem.CommandError); ok {
|
||||
detail = strings.ReplaceAll(detail, commandErr.Error(), safeCommandError(commandErr))
|
||||
}
|
||||
switch wrapped := err.(type) {
|
||||
case interface{ Unwrap() []error }:
|
||||
for _, child := range wrapped.Unwrap() {
|
||||
detail = redactCommandErrors(detail, child)
|
||||
}
|
||||
case interface{ Unwrap() error }:
|
||||
if child := wrapped.Unwrap(); child != nil {
|
||||
detail = redactCommandErrors(detail, child)
|
||||
}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func safeCommandError(err *modem.CommandError) string {
|
||||
command := safeATCommandName(err.Command)
|
||||
final := strings.TrimSpace(err.Final)
|
||||
if final == "" {
|
||||
final = "unknown modem error"
|
||||
}
|
||||
return command + " failed: " + final
|
||||
}
|
||||
|
||||
func safeATCommandName(command string) string {
|
||||
command = strings.ToUpper(strings.TrimSpace(command))
|
||||
if command == "" {
|
||||
return "AT command"
|
||||
}
|
||||
if strings.HasPrefix(command, "ATD") {
|
||||
return "ATD"
|
||||
}
|
||||
for index, character := range command {
|
||||
if character == '=' || character == '?' || character == ',' ||
|
||||
character == '"' || unicode.IsSpace(character) {
|
||||
command = command[:index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(command, "AT") || len(command) > 32 {
|
||||
return "AT command"
|
||||
}
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestHardwareErrorDetailRedactsATPayload(t *testing.T) {
|
||||
const payload = "00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"
|
||||
commandErr := &modem.CommandError{
|
||||
Command: `AT+CSIM=78,"` + payload + `"`,
|
||||
Final: "+CME ERROR: 13",
|
||||
Lines: []string{payload},
|
||||
}
|
||||
err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), commandErr))
|
||||
detail := HardwareErrorDetail(err)
|
||||
if strings.Contains(detail, payload) || strings.Contains(detail, "AT+CSIM=") {
|
||||
t.Fatalf("hardware error exposed AT payload: %q", detail)
|
||||
}
|
||||
if !strings.Contains(detail, "select ISIM") || !strings.Contains(detail, "AT+CSIM failed: +CME ERROR: 13") {
|
||||
t.Fatalf("hardware error lost useful diagnostics: %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLogsNewHardwareFailuresWithoutPollingSpam(t *testing.T) {
|
||||
commandError := func() error {
|
||||
return &modem.CommandError{Command: "AT+CSQ", Final: "+CME ERROR: 13"}
|
||||
}
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+CSQ", err: commandError()},
|
||||
{command: "AT+CSQ", err: commandError()},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
||||
{command: "AT+CSQ", err: commandError()},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
hub := loghub.New(nil, 100)
|
||||
manager.logger = slog.New(hub)
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
||||
}
|
||||
if entries := hub.History(10, slog.LevelDebug, ""); len(entries) != 1 {
|
||||
t.Fatalf("continuous failure produced %d log entries, want 1", len(entries))
|
||||
}
|
||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
||||
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
|
||||
|
||||
entries := hub.History(10, slog.LevelDebug, "")
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("failure after recovery produced %d total log entries, want 2", len(entries))
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Message != "hardware operation failed" || entry.Fields["device_id"] != id {
|
||||
t.Fatalf("hardware log entry = %#v", entry)
|
||||
}
|
||||
if entry.Fields["error"] != "AT+CSQ failed: +CME ERROR: 13" {
|
||||
t.Fatalf("hardware log detail = %#v", entry.Fields["error"])
|
||||
}
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -21,6 +22,7 @@ type Options struct {
|
||||
SMSTimeout time.Duration
|
||||
ScanTimeout time.Duration
|
||||
CardReaders *pcsc.Service
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -38,6 +40,7 @@ type Manager struct {
|
||||
smsTimeout time.Duration
|
||||
scanTimeout time.Duration
|
||||
cardReaders *pcsc.Service
|
||||
logger *slog.Logger
|
||||
|
||||
qmiRadioOpener qmiRadioSessionOpener
|
||||
nativeQMIRegistrationMu sync.Mutex
|
||||
@@ -120,6 +123,7 @@ func NewManager(options Options) (*Manager, error) {
|
||||
smsTimeout: options.SMSTimeout,
|
||||
scanTimeout: options.ScanTimeout,
|
||||
cardReaders: options.CardReaders,
|
||||
logger: options.Logger,
|
||||
|
||||
qmiRadioOpener: openQMIRadioSession,
|
||||
nativeQMIRegistrationInFlight: make(map[string]struct{}),
|
||||
@@ -373,10 +377,11 @@ func (manager *Manager) setResult(
|
||||
err error,
|
||||
) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.devices[id] != state {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
previousError := state.lastError
|
||||
if snapshot != nil {
|
||||
value := *snapshot
|
||||
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
||||
@@ -388,6 +393,19 @@ func (manager *Manager) setResult(
|
||||
} else {
|
||||
state.lastError = ""
|
||||
}
|
||||
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
|
||||
backend := state.backend
|
||||
hardwareKind := state.candidate.HardwareKind
|
||||
manager.mu.Unlock()
|
||||
if shouldLog {
|
||||
manager.logger.Warn(
|
||||
"hardware operation failed",
|
||||
"device_id", id,
|
||||
"backend", backend,
|
||||
"hardware_kind", hardwareKind,
|
||||
"error", HardwareErrorDetail(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
||||
|
||||
@@ -74,6 +74,17 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) {
|
||||
grouped := MCCsByCountry()
|
||||
if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" {
|
||||
t.Fatalf("GB MCCs = %#v", got)
|
||||
}
|
||||
grouped["GB"][0] = "999"
|
||||
if country, ok := CountryForMCC("234"); !ok || country != "GB" {
|
||||
t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
@@ -110,3 +121,22 @@ func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForSIMRecognizesGiffgaffWithoutRelabelingGenericO2(t *testing.T) {
|
||||
for _, identity := range []CarrierIdentity{
|
||||
{IMSI: "234100000000001", GID1: "508FFFFF", MNCLength: 2},
|
||||
{IMSI: "234100000000001", SPN: "GiffGaff", MNCLength: 2},
|
||||
} {
|
||||
plmn, name, country, ok := CarrierForSIM(identity)
|
||||
if !ok || plmn != "23410" || name != "giffgaff" || country != "GB" {
|
||||
t.Fatalf("giffgaff identity = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
_, name, _, ok := CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "234100000000001", MNCLength: 2,
|
||||
})
|
||||
if !ok || name == "giffgaff" {
|
||||
t.Fatalf("generic O2 SIM was mislabeled as giffgaff: (%q, %v)", name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -69,7 +69,12 @@ func (manager *Manager) readSnapshot(
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
|
||||
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
|
||||
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
|
||||
// timeout, and every refresh holds the device lock while it does so,
|
||||
// starving the AT terminal. Only fall back to QMI when the AT CPIN
|
||||
// probe already proved a card is present.
|
||||
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
|
||||
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
|
||||
cancelQMI()
|
||||
@@ -186,14 +191,18 @@ func (manager *Manager) readSnapshot(
|
||||
snapshot.RegistrationSource = "COPS"
|
||||
}
|
||||
if snapshot.IMEI == "" {
|
||||
response, ok := optional("AT+CGSN")
|
||||
if ok {
|
||||
snapshot.IMEI = parseIdentifier(
|
||||
response,
|
||||
[]string{"+CGSN:", "+GSN:"},
|
||||
14,
|
||||
17,
|
||||
)
|
||||
// AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
|
||||
// returns the IMEI line but never a final OK, so it would block until the
|
||||
// caller's deadline (30s during a periodic refresh) and starve every other
|
||||
// device operation behind the lock. Give it an independent short timeout
|
||||
// and let the WWAN transport's drain discard the trailing stale bytes.
|
||||
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
|
||||
cancelCGSN()
|
||||
if cgsnErr == nil {
|
||||
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
|
||||
snapshot.IMEI = imei
|
||||
}
|
||||
}
|
||||
}
|
||||
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
|
||||
@@ -211,7 +220,6 @@ func (manager *Manager) readSnapshot(
|
||||
// Preserve a prior successful read across a transient QMI/AT failure.
|
||||
snapshot.IMEI = previousSnapshot.IMEI
|
||||
}
|
||||
|
||||
if response, ok := optional("AT+CFUN?"); ok {
|
||||
if mode, found := parseCFUN(response); found {
|
||||
snapshot.OperatingMode = mode
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
// lenientATClient answers every command with a bare CommandError and records
|
||||
// the commands it saw. It lets snapshot tests exercise the full readSnapshot
|
||||
// sequence without enumerating every step of the transcript.
|
||||
type lenientATClient struct {
|
||||
mu sync.Mutex
|
||||
commands []string
|
||||
cgsnDelay time.Duration
|
||||
cgsnIMEI string
|
||||
}
|
||||
|
||||
func (c *lenientATClient) Execute(ctx context.Context, command string) (modem.Response, error) {
|
||||
c.mu.Lock()
|
||||
c.commands = append(c.commands, command)
|
||||
c.mu.Unlock()
|
||||
if command == "ATI" {
|
||||
return okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410"), nil
|
||||
}
|
||||
if command == "AT+CGSN" && c.cgsnDelay > 0 {
|
||||
select {
|
||||
case <-time.After(c.cgsnDelay):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
if command == "AT+CGSN" && c.cgsnIMEI != "" {
|
||||
return okResponse("+CGSN: " + c.cgsnIMEI), nil
|
||||
}
|
||||
return modem.Response{}, &modem.CommandError{Command: command, Final: "ERROR"}
|
||||
}
|
||||
|
||||
func (c *lenientATClient) WaitURC(context.Context, func(string) bool) (string, error) {
|
||||
return "", errors.New("no URC")
|
||||
}
|
||||
|
||||
func (c *lenientATClient) Close() error { return nil }
|
||||
|
||||
func (c *lenientATClient) saw(command string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, seen := range c.commands {
|
||||
if seen == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AT+CGSN on some MHI modems returns the IMEI line but never a final OK, so it
|
||||
// would block until the caller's deadline and hold the device lock for the
|
||||
// whole periodic refresh. The snapshot must bound CGSN with its own short
|
||||
// timeout instead of inheriting the refresh deadline.
|
||||
func TestManagerRefreshBoundsCGSNTimeout(t *testing.T) {
|
||||
client := &lenientATClient{cgsnDelay: 5 * time.Second}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
snapshot, err := manager.Refresh(ctx, id)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
// CGSN times out after CommandTimeout (1s in the test manager); the rest
|
||||
// of the snapshot is immediate. An un-bounded CGSN would wait for the
|
||||
// 4s outer deadline (or worse, a real 30s refresh deadline).
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("Refresh took %s; CGSN was not bounded by CommandTimeout", elapsed)
|
||||
}
|
||||
if !client.saw("AT+CGSN") {
|
||||
t.Fatalf("CGSN was never sent; commands = %v", client.commands)
|
||||
}
|
||||
if snapshot.IMEI != "" {
|
||||
t.Fatalf("IMEI = %q, want empty after CGSN timeout", snapshot.IMEI)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing SIM must not fall back to the QMI UIM ICCID read: without a READY
|
||||
// card that call blocks until its long timeout and starves the AT terminal
|
||||
// behind the device lock.
|
||||
func TestManagerRefreshSkipsQMIICCIDWithoutReadySIM(t *testing.T) {
|
||||
// CGSN succeeds so the snapshot does not fall back to the QMI DMS IMEI
|
||||
// read either; the test focuses on the UIM ICCID fallback being skipped
|
||||
// without a READY card.
|
||||
client := &lenientATClient{cgsnIMEI: "866241014372802"}
|
||||
manager, err := NewManager(Options{
|
||||
Discoverer: staticDiscoverer{candidates: []modem.Candidate{{
|
||||
ID: "mhi-wwan0",
|
||||
Product: "PCIe/MHI WWAN modem",
|
||||
QMIControl: "/dev/wwan0qmi0",
|
||||
NetworkInterface: "wwan0",
|
||||
ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT},
|
||||
}}},
|
||||
Opener: &staticOpener{client: client},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||
|
||||
qmiCalls := 0
|
||||
manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) {
|
||||
qmiCalls++
|
||||
return nil, errors.New("QMI should not be opened without a SIM")
|
||||
}
|
||||
if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0")
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
// Exactly one QMI open is expected: the immutable DMS IMEI read runs
|
||||
// unconditionally for native QMI candidates (IMEI is hardware identity,
|
||||
// independent of the card). The UIM ICCID fallback, which would block
|
||||
// without a READY SIM, must be skipped.
|
||||
if qmiCalls != 1 {
|
||||
t.Fatalf("qmiRadioOpener called %d times, want 1 (DMS IMEI only, UIM ICCID must be skipped without a READY SIM)", qmiCalls)
|
||||
}
|
||||
for _, warning := range snapshot.Warnings {
|
||||
if strings.Contains(warning, "QMI UIM") {
|
||||
t.Fatalf("unexpected QMI ICCID warning: %q", warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package i18n
|
||||
|
||||
// Keep feature-specific diagnostic strings together so additions to the proxy
|
||||
// probe do not cause conflicts in the shared dictionary.
|
||||
func init() {
|
||||
zhToEn["UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。"] = "UDP ASSOCIATE was established, but no UDP payload returned; check the node's UDP forwarding, routing, and firewall."
|
||||
zhToEn["TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。"] = "TCP handshake, authentication, UDP ASSOCIATE, and a real UDP DNS round trip all passed."
|
||||
zhToEn["代理已保存,SOCKS5 认证与真实 UDP 往返均通过。"] = "Proxy saved; SOCKS5 authentication and a real UDP round trip both passed."
|
||||
zhToEn["SOCKS5 认证与真实 UDP 往返探测通过。"] = "SOCKS5 authentication and a real UDP round-trip probe passed."
|
||||
}
|
||||
+78
-10
@@ -11,8 +11,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const quectelVendorID = "2c7c"
|
||||
|
||||
const (
|
||||
djiVendorID = "2ca3"
|
||||
dji4GProductID = "4006"
|
||||
)
|
||||
type SysFSDiscoverer struct {
|
||||
SysRoot string
|
||||
DevRoot string
|
||||
@@ -40,10 +42,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
if os.IsNotExist(err) {
|
||||
entries = nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||
return nil, fmt.Errorf("discover USB QMI modems: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate modems are identified by kernel driver binding instead of a
|
||||
// vendor-ID whitelist. qmi_wwan only binds Qualcomm QMI control interfaces,
|
||||
// so any USB device with a bound interface exposes a live QMI channel. This
|
||||
// keeps discovery vendor-neutral (SIMCom, Sierra, Telit and other
|
||||
// Qualcomm-based modems are found automatically) while MBIM-only devices
|
||||
// stay out, because cdc_mbim binds their control interface instead and the
|
||||
// project has no MBIM backend.
|
||||
qmiBound := d.qmiWWANBoundDevices()
|
||||
|
||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||
devices := make(map[string]*discoveredUSBDevice)
|
||||
for _, entry := range entries {
|
||||
@@ -70,17 +81,17 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
resolvedDevice = devicePath
|
||||
}
|
||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||
if vendorID != quectelVendorID {
|
||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
||||
continue
|
||||
}
|
||||
|
||||
state := devices[deviceName]
|
||||
if state == nil {
|
||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||
state = &discoveredUSBDevice{
|
||||
candidate: Candidate{
|
||||
ID: candidateID(productID, serialNumber, deviceName),
|
||||
ID: candidateID(vendorID, productID, serialNumber, deviceName),
|
||||
VendorID: vendorID,
|
||||
ProductID: productID,
|
||||
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
||||
@@ -130,6 +141,14 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
})
|
||||
assignQuectelPortRoles(state.candidate.Ports)
|
||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||
if !state.candidate.HasATPort() {
|
||||
// A 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)
|
||||
}
|
||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||
@@ -141,6 +160,14 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
||||
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
||||
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
||||
func IsDJI4GUSB(vendorID, productID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(vendorID), djiVendorID) &&
|
||||
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
||||
}
|
||||
|
||||
type discoveredWWANDevice struct {
|
||||
index string
|
||||
ports []Port
|
||||
@@ -235,7 +262,7 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||
}
|
||||
if len(group.ports) > 0 {
|
||||
candidate.ATPort = group.ports[0]
|
||||
candidate.ATPort = selectWWANATPort(group.ports)
|
||||
}
|
||||
if len(group.qmiNames) > 0 {
|
||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||
@@ -246,6 +273,20 @@ func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// selectWWANATPort prefers the secondary AT port (…at1) over the primary
|
||||
// (…at0) when both exist, falling back to the first AT port otherwise. Some
|
||||
// Qualcomm MHI modems (notably the UFI dongle behind the OpenStick 410) answer
|
||||
// on at1 immediately while at0 delays every response by 10-20 seconds, so the
|
||||
// secondary port is the usable AT channel.
|
||||
func selectWWANATPort(ports []Port) Port {
|
||||
for _, port := range ports {
|
||||
if port.InterfaceNumber == 1 {
|
||||
return port
|
||||
}
|
||||
}
|
||||
return ports[0]
|
||||
}
|
||||
|
||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||
if !strings.HasPrefix(name, "wwan") {
|
||||
return "", "", 0, false
|
||||
@@ -385,7 +426,34 @@ func readSerialAliases(root string) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func candidateID(productID, serialNumber, usbName string) string {
|
||||
// qmiWWANBoundDevices returns the set of USB device paths (for example "1-6"
|
||||
// or the hub-attached "1-4.3.2") that currently have at least one interface
|
||||
// bound to the kernel's qmi_wwan driver. Interface entries in the driver
|
||||
// directory are named "<device-path>:<interface>.<altsetting>", so the part
|
||||
// before the first colon is the owning USB device. The qmi_wwan driver only
|
||||
// binds Qualcomm QMI control interfaces, so membership doubles as a vendor-
|
||||
// neutral "this is a live QMI modem" signal.
|
||||
func (d *SysFSDiscoverer) qmiWWANBoundDevices() map[string]struct{} {
|
||||
driverRoot := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||
entries, err := os.ReadDir(driverRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
devices := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
// The driver directory also holds control files (bind, unbind, uevent,
|
||||
// module, new_id, ...); only names containing a colon are interfaces.
|
||||
deviceName, _, ok := strings.Cut(entry.Name(), ":")
|
||||
if !ok || deviceName == "" {
|
||||
continue
|
||||
}
|
||||
devices[deviceName] = struct{}{}
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
||||
prefix := "usb-" + sanitizeID(vendorID)
|
||||
serialNumber = strings.TrimSpace(serialNumber)
|
||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||
@@ -394,9 +462,9 @@ func candidateID(productID, serialNumber, usbName string) string {
|
||||
// to the same hub into one entry. Include the physical USB topology in the
|
||||
// discovery key; configured devices remain stable through ATMapper's
|
||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
}
|
||||
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
||||
return prefix + "-" + sanitizeID(productID+"-"+usbName)
|
||||
}
|
||||
|
||||
func sanitizeID(value string) string {
|
||||
|
||||
@@ -2,6 +2,29 @@ package modem
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsDJI4GUSBIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vendorID string
|
||||
productID string
|
||||
want bool
|
||||
}{
|
||||
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
||||
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
||||
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
||||
{name: "Quectel identity", vendorID: "2c7c", productID: "0125", want: false},
|
||||
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := IsDJI4GUSB(test.vendorID, test.productID); got != test.want {
|
||||
t.Fatalf("IsDJI4GUSB(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
||||
ports := []Port{
|
||||
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||
|
||||
discoverer := NewSysFSDiscoverer(sysRoot, devRoot)
|
||||
candidates, err := discoverer.Discover(context.Background())
|
||||
@@ -57,7 +59,7 @@ func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ID != "quectel-0125-1-6" {
|
||||
if candidate.ID != "usb-2c7c-0125-1-6" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Name != "ttyUSB2" {
|
||||
@@ -101,6 +103,7 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
||||
)
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-6:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
@@ -146,6 +149,7 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
||||
}
|
||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||
mustBindQMIWWAN(t, sysRoot, modem.usbName+":1.4")
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
@@ -194,6 +198,7 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||
mustBindQMIWWAN(t, sysRoot, item.usbName+":1.4")
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
@@ -216,9 +221,11 @@ func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
func TestSysFSDiscoveryIgnoresUSBWithoutQMIWWANBinding(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||
// A plain USB serial adapter (FTDI) exposes ttyUSB but no QMI interface and
|
||||
// is never bound to qmi_wwan, so it must not be treated as a modem.
|
||||
mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9"))
|
||||
@@ -235,6 +242,117 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsNonQuectelVendorBoundToQMIWWAN(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// A Sierra EM7430 flashed to its QMI (rmnet0) composition: non-Quectel
|
||||
// vendor, but its control interface is bound to qmi_wwan.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idVendor"), "1199\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "idProduct"), "9077\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "manufacturer"), "Sierra Wireless, Incorporated\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-3", "product"), "EM7430\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-3:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-3:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-3:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.VendorID != "1199" || candidate.Product != "EM7430" {
|
||||
t.Fatalf("candidate = %#v", candidate)
|
||||
}
|
||||
if candidate.ID != "usb-1199-9077-1-3" {
|
||||
t.Fatalf("ID = %q", candidate.ID)
|
||||
}
|
||||
if candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if candidate.DiscoveryIssue != "" {
|
||||
t.Fatalf("discovery issue = %q, want none", candidate.DiscoveryIssue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryMarksQMIModemWithoutATPort(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// QMI control interface is bound, but no ttyUSB/ttyACM node exists (for
|
||||
// example the option/qcserial driver does not claim the serial interfaces).
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-7", "idProduct"), "0125\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-7:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-7:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.DiscoveryIssue != "at_port_missing" {
|
||||
t.Fatalf("discovery issue = %q, want at_port_missing", candidate.DiscoveryIssue)
|
||||
}
|
||||
if candidate.HasATPort() {
|
||||
t.Fatalf("candidate unexpectedly has an AT port: %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsHubAttachedQMIWWANDevice(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
// Device behind a hub: the USB path "1-4.3.2" contains extra segments, and
|
||||
// the qmi_wwan binding uses the same composite path before the colon.
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, "1-4.3.2", "idProduct"), "0125\n")
|
||||
for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} {
|
||||
interfaceName := "1-4.3.2:1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, "1-4.3.2:1.4", "usbmisc", "cdc-wdm0"))
|
||||
mustBindQMIWWAN(t, sysRoot, "1-4.3.2:1.4")
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ATPort.Name != "ttyUSB2" {
|
||||
t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if !strings.Contains(candidate.ID, "1-4-3-2") {
|
||||
t.Fatalf("ID = %q, want hub topology in discovery key", candidate.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
@@ -256,7 +374,7 @@ func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||
t.Fatalf("identity = %#v", candidate)
|
||||
}
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at1") || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||
@@ -290,6 +408,23 @@ func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectWWANATPortPrefersSecondaryATPort(t *testing.T) {
|
||||
ports := []Port{
|
||||
{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT},
|
||||
{Name: "wwan0at1", InterfaceNumber: 1, Role: PortRoleAT},
|
||||
}
|
||||
if got := selectWWANATPort(ports); got.Name != "wwan0at1" {
|
||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectWWANATPortFallsBackToPrimaryWhenOnlyAT0(t *testing.T) {
|
||||
ports := []Port{{Name: "wwan0at0", InterfaceNumber: 0, Role: PortRoleAT}}
|
||||
if got := selectWWANATPort(ports); got.Name != "wwan0at0" {
|
||||
t.Fatalf("selectWWANATPort = %#v, want wwan0at0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWWANPortName(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, index, kind string
|
||||
@@ -323,3 +458,18 @@ func mustMkdir(t *testing.T, path string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustBindQMIWWAN mimics the kernel's driver-binding directory entry: it adds
|
||||
// interfaceName (e.g. "1-6:1.4") under /sys/bus/usb/drivers/qmi_wwan exactly
|
||||
// like the real qmi_wwan driver directory does for a bound QMI interface.
|
||||
func mustBindQMIWWAN(t *testing.T, sysRoot, interfaceName string) {
|
||||
t.Helper()
|
||||
driverDir := filepath.Join(sysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
||||
if err := os.MkdirAll(driverDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(sysRoot, "bus", "usb", "devices", interfaceName)
|
||||
if err := os.Symlink(target, filepath.Join(driverDir, interfaceName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -147,14 +148,19 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
||||
if err := ctx.Err(); err != nil {
|
||||
return response, err
|
||||
}
|
||||
// Drain the transport before writing the command. Serial transports wait
|
||||
// for any pending output here (a no-op after a synchronous command), while
|
||||
// WWAN transports discard bytes left over from a previous command that
|
||||
// timed out; without this, a late reply (e.g. a slow CGSN response) would
|
||||
// be mis-parsed as this command's output.
|
||||
if err := drainTransport(ctx, session.transport); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||
}
|
||||
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("write %s: %w", command, err)
|
||||
}
|
||||
if err := session.transport.Drain(); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||
}
|
||||
return session.readFinalLocked(ctx, started, command, "", response)
|
||||
}
|
||||
|
||||
@@ -178,7 +184,7 @@ func (session *Session) executePromptLocked(
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("write %s: %w", command, err)
|
||||
}
|
||||
if err := session.transport.Drain(); err != nil {
|
||||
if err := drainTransport(ctx, session.transport); err != nil {
|
||||
session.poisonLocked()
|
||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||
}
|
||||
@@ -203,7 +209,7 @@ func (session *Session) executePromptLocked(
|
||||
response.Duration = time.Since(started)
|
||||
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
||||
}
|
||||
if err := session.transport.Drain(); err != nil {
|
||||
if err := drainTransport(ctx, session.transport); err != nil {
|
||||
session.poisonLocked()
|
||||
response.Duration = time.Since(started)
|
||||
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
||||
@@ -211,6 +217,21 @@ func (session *Session) executePromptLocked(
|
||||
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
||||
}
|
||||
|
||||
// drainTransport retries tcdrain/TCSBRK when the kernel interrupts it with a
|
||||
// signal. go.bug.st/serial already retries EINTR for Read, but its Linux
|
||||
// Drain implementation currently returns the transient error directly.
|
||||
func drainTransport(ctx context.Context, transport Transport) error {
|
||||
for {
|
||||
err := transport.Drain()
|
||||
if !errors.Is(err, syscall.EINTR) {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) readFinalLocked(
|
||||
ctx context.Context,
|
||||
started time.Time,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -27,6 +28,8 @@ type transcriptTransport struct {
|
||||
unexpected error
|
||||
writePartial bool
|
||||
writeEvents chan string
|
||||
drainErrors []error
|
||||
drainCount int
|
||||
}
|
||||
|
||||
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
||||
@@ -114,7 +117,17 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (transport *transcriptTransport) Drain() error { return nil }
|
||||
func (transport *transcriptTransport) Drain() error {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
transport.drainCount++
|
||||
if len(transport.drainErrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := transport.drainErrors[0]
|
||||
transport.drainErrors = transport.drainErrors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (transport *transcriptTransport) ResetInputBuffer() error {
|
||||
transport.mu.Lock()
|
||||
@@ -138,6 +151,31 @@ func (transport *transcriptTransport) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSessionRetriesInterruptedDrain(t *testing.T) {
|
||||
transport := &transcriptTransport{
|
||||
steps: []transportStep{{
|
||||
write: "AT+CSQ\r",
|
||||
chunks: []string{"\r\nAT+CSQ\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||
}},
|
||||
drainErrors: []error{syscall.EINTR},
|
||||
}
|
||||
session, err := NewSession(transport, SessionOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSession() error = %v", err)
|
||||
}
|
||||
|
||||
response, err := session.Execute(context.Background(), "AT+CSQ")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if response.Final != "OK" {
|
||||
t.Fatalf("response final = %q", response.Final)
|
||||
}
|
||||
if transport.drainCount != 2 {
|
||||
t.Fatalf("Drain() calls = %d, want 2", transport.drainCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
||||
transport := &transcriptTransport{steps: []transportStep{{
|
||||
write: "AT+CSQ\r",
|
||||
@@ -415,6 +453,62 @@ func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// drainOrderTransport forwards to an inner Transport while recording
|
||||
// transport-level events, so a test can assert the exact order of Drain and
|
||||
// Write calls.
|
||||
type drainOrderTransport struct {
|
||||
inner Transport
|
||||
events chan string
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Write(payload []byte) (int, error) {
|
||||
transport.events <- "write:" + string(payload)
|
||||
return transport.inner.Write(payload)
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Read(buffer []byte) (int, error) {
|
||||
return transport.inner.Read(buffer)
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Drain() error {
|
||||
transport.events <- "drain"
|
||||
return transport.inner.Drain()
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) ResetInputBuffer() error {
|
||||
return transport.inner.ResetInputBuffer()
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) SetReadTimeout(timeout time.Duration) error {
|
||||
return transport.inner.SetReadTimeout(timeout)
|
||||
}
|
||||
|
||||
func (transport *drainOrderTransport) Close() error {
|
||||
return transport.inner.Close()
|
||||
}
|
||||
|
||||
// WWAN transports discard stale bytes left over from a timed-out command
|
||||
// inside Drain, so the session must call it before writing the next command;
|
||||
// otherwise a late reply (e.g. a slow CGSN response) would be mis-parsed as
|
||||
// the new command's output.
|
||||
func TestSessionDrainsBeforeWritingCommand(t *testing.T) {
|
||||
inner := &transcriptTransport{steps: []transportStep{{
|
||||
write: "AT+CSQ\r",
|
||||
chunks: []string{"\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||
}}}
|
||||
events := make(chan string, 8)
|
||||
session := newTestSession(t, &drainOrderTransport{inner: inner, events: events})
|
||||
if _, err := session.Execute(context.Background(), "AT+CSQ"); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if first := <-events; first != "drain" {
|
||||
t.Fatalf("first transport event = %q, want drain before the command write", first)
|
||||
}
|
||||
if second := <-events; second != "write:AT+CSQ\r" {
|
||||
t.Fatalf("second transport event = %q, want the command write", second)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestSession(t *testing.T, transport Transport) *Session {
|
||||
t.Helper()
|
||||
session, err := NewSession(transport, SessionOptions{
|
||||
|
||||
@@ -108,8 +108,27 @@ func (transport *nativeWWANATTransport) Drain() error {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
// WWAN character-device writes are handed to the modem synchronously and
|
||||
// have no termios output queue to drain.
|
||||
return nil
|
||||
// have no termios output queue to drain. A previous command that timed out
|
||||
// can leave late bytes in the input buffer (e.g. a slow CGSN reply that
|
||||
// arrives after the command deadline); discard them here so the next
|
||||
// command starts from a clean stream instead of mis-parsing stale output.
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := unix.Read(transport.fd, buffer); err != nil {
|
||||
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//go:build linux
|
||||
|
||||
package modem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// TestNativeWWANATTransportDrainDiscardsPendingBytes verifies Drain discards
|
||||
// every byte already buffered on the transport. A command that timed out (e.g.
|
||||
// AT+CGSN on an MHI modem that never answers OK) can leave its late reply in
|
||||
// the input buffer; the next command's Drain must clear it, however much data
|
||||
// is pending, before the session writes the new command.
|
||||
func TestNativeWWANATTransportDrainDiscardsPendingBytes(t *testing.T) {
|
||||
readFD, writeFD := socketpair(t)
|
||||
defer unix.Close(writeFD)
|
||||
|
||||
// More than one 4096-byte Drain read: a slow CGSN reply (echo + IMEI +
|
||||
// trailing CRLF) can exceed a single buffer.
|
||||
payload := make([]byte, 12000)
|
||||
for index := range payload {
|
||||
payload[index] = byte('A' + index%26)
|
||||
}
|
||||
payload = append(payload, []byte("\r\n+CGSN: 357091089453326\r\n")...)
|
||||
if _, err := unix.Write(writeFD, payload); err != nil {
|
||||
t.Fatalf("seed stale bytes: %v", err)
|
||||
}
|
||||
|
||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||
if err := transport.Drain(); err != nil {
|
||||
t.Fatalf("Drain: %v", err)
|
||||
}
|
||||
assertNoPendingBytes(t, readFD, "after Drain")
|
||||
|
||||
// Draining a clean transport is a fast no-op that must not block or error.
|
||||
if err := transport.Drain(); err != nil {
|
||||
t.Fatalf("second Drain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNativeWWANATTransportDrainRejectsClosedTransport covers the guard that
|
||||
// keeps a poisoned session from draining a wedged, already-closed fd.
|
||||
func TestNativeWWANATTransportDrainRejectsClosedTransport(t *testing.T) {
|
||||
readFD, writeFD := socketpair(t)
|
||||
defer unix.Close(writeFD)
|
||||
transport := &nativeWWANATTransport{fd: readFD, readTimeout: -1}
|
||||
if err := transport.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := transport.Drain(); !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("Drain after Close = %v, want ErrClosedPipe", err)
|
||||
}
|
||||
}
|
||||
|
||||
func socketpair(t *testing.T) (int, int) {
|
||||
t.Helper()
|
||||
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fds[0], fds[1]
|
||||
}
|
||||
|
||||
func assertNoPendingBytes(t *testing.T, fd int, context string) {
|
||||
t.Helper()
|
||||
fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
||||
ready, err := unix.Poll(fds, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("poll %s: %v", context, err)
|
||||
}
|
||||
if ready != 0 {
|
||||
t.Fatalf("%s: fd still readable", context)
|
||||
}
|
||||
}
|
||||
+246
-1
@@ -3,6 +3,8 @@ package proxy
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,18 +19,44 @@ type ProbeResult struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
HandshakeOK bool `json:"handshake_ok"`
|
||||
UDPAssociateOK bool `json:"udp_associate_ok"`
|
||||
UDPExchangeOK bool `json:"udp_exchange_ok"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
RelayAddr string `json:"relay_addr,omitempty"`
|
||||
DNSServer string `json:"dns_server,omitempty"`
|
||||
DNSName string `json:"dns_name,omitempty"`
|
||||
DNSRCode int `json:"dns_rcode,omitempty"`
|
||||
RoundTripMS int64 `json:"round_trip_ms,omitempty"`
|
||||
Diagnosis string `json:"diagnosis,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultProbeDNSServer = "1.1.1.1:53"
|
||||
defaultProbeDNSName = "example.com"
|
||||
)
|
||||
|
||||
func ProbeSOCKS5(
|
||||
ctx context.Context,
|
||||
address string,
|
||||
username string,
|
||||
password string,
|
||||
timeout time.Duration,
|
||||
) (ProbeResult, error) {
|
||||
return probeSOCKS5(ctx, address, username, password, timeout, defaultProbeDNSServer, defaultProbeDNSName)
|
||||
}
|
||||
|
||||
// probeSOCKS5 performs both the SOCKS5 control-plane negotiation and a real
|
||||
// UDP DNS round trip through the returned relay. Keeping the target injectable
|
||||
// makes the negative paths deterministic in tests without weakening the
|
||||
// production probe.
|
||||
func probeSOCKS5(
|
||||
ctx context.Context,
|
||||
address string,
|
||||
username string,
|
||||
password string,
|
||||
timeout time.Duration,
|
||||
dnsServer string,
|
||||
dnsName string,
|
||||
) (ProbeResult, error) {
|
||||
address = strings.TrimSpace(address)
|
||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||
@@ -122,11 +150,228 @@ func ProbeSOCKS5(
|
||||
port := int(portBytes[0])<<8 | int(portBytes[1])
|
||||
result.UDPAssociateOK = true
|
||||
result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
result.DNSServer = dnsServer
|
||||
result.DNSName = dnsName
|
||||
|
||||
if err := probeUDPExchange(probeContext, connection, &result, host, port, dnsServer, dnsName, timeout); err != nil {
|
||||
if result.Diagnosis == "" {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
}
|
||||
if result.Hint == "" {
|
||||
result.Hint = i18n.T("UDP ASSOCIATE 已建立,但实际 UDP 数据没有返回;检查节点 UDP 转发、路由和防火墙。")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
result.Diagnosis = "ready"
|
||||
result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。")
|
||||
result.Hint = i18n.T("TCP 握手、认证、UDP ASSOCIATE 与真实 UDP DNS 往返均通过。")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func probeUDPExchange(
|
||||
ctx context.Context,
|
||||
control net.Conn,
|
||||
result *ProbeResult,
|
||||
relayHost string,
|
||||
relayPort int,
|
||||
dnsServer string,
|
||||
dnsName string,
|
||||
timeout time.Duration,
|
||||
) error {
|
||||
if result == nil {
|
||||
return errors.New("proxy: probe result is nil")
|
||||
}
|
||||
dnsAddress, err := net.ResolveUDPAddr("udp", strings.TrimSpace(dnsServer))
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_target"
|
||||
return fmt.Errorf("proxy: resolve UDP probe target: %w", err)
|
||||
}
|
||||
relayHost = strings.TrimSpace(relayHost)
|
||||
if relayIP := net.ParseIP(relayHost); relayIP != nil && relayIP.IsUnspecified() {
|
||||
remoteHost, _, splitErr := net.SplitHostPort(control.RemoteAddr().String())
|
||||
if splitErr != nil {
|
||||
result.Diagnosis = "invalid_udp_relay"
|
||||
return fmt.Errorf("proxy: resolve wildcard UDP relay: %w", splitErr)
|
||||
}
|
||||
relayHost = remoteHost
|
||||
}
|
||||
relayAddress, err := net.ResolveUDPAddr("udp", net.JoinHostPort(relayHost, fmt.Sprintf("%d", relayPort)))
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_udp_relay"
|
||||
return fmt.Errorf("proxy: resolve UDP relay: %w", err)
|
||||
}
|
||||
|
||||
localNetwork := "udp4"
|
||||
if relayAddress.IP != nil && relayAddress.IP.To4() == nil {
|
||||
localNetwork = "udp6"
|
||||
}
|
||||
udpConnection, err := net.ListenUDP(localNetwork, nil)
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_socket_failed"
|
||||
return fmt.Errorf("proxy: open UDP probe socket: %w", err)
|
||||
}
|
||||
defer udpConnection.Close()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
}
|
||||
if err := udpConnection.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("proxy: set UDP probe deadline: %w", err)
|
||||
}
|
||||
|
||||
query, queryID, err := buildDNSQuery(dnsName)
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_name"
|
||||
return err
|
||||
}
|
||||
datagram, err := buildSOCKSUDPDatagram(dnsAddress, query)
|
||||
if err != nil {
|
||||
result.Diagnosis = "invalid_dns_target"
|
||||
return err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
if _, err := udpConnection.WriteToUDP(datagram, relayAddress); err != nil {
|
||||
result.Diagnosis = "udp_send_failed"
|
||||
return fmt.Errorf("proxy: send UDP DNS probe: %w", err)
|
||||
}
|
||||
|
||||
responseBuffer := make([]byte, 64*1024)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
return fmt.Errorf("proxy: UDP DNS probe cancelled: %w", err)
|
||||
}
|
||||
count, sender, err := udpConnection.ReadFromUDP(responseBuffer)
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_no_roundtrip"
|
||||
return fmt.Errorf("proxy: UDP DNS probe did not return: %w", err)
|
||||
}
|
||||
if !sameUDPAddress(sender, relayAddress) {
|
||||
continue
|
||||
}
|
||||
payload, err := parseSOCKSUDPDatagram(responseBuffer[:count])
|
||||
if err != nil {
|
||||
result.Diagnosis = "udp_invalid_response"
|
||||
return fmt.Errorf("proxy: parse UDP relay response: %w", err)
|
||||
}
|
||||
rcode, err := validateDNSResponse(payload, queryID)
|
||||
if err != nil {
|
||||
result.Diagnosis = "dns_invalid_response"
|
||||
return err
|
||||
}
|
||||
result.UDPExchangeOK = true
|
||||
result.DNSRCode = rcode
|
||||
result.RoundTripMS = time.Since(startedAt).Milliseconds()
|
||||
if result.RoundTripMS < 1 {
|
||||
result.RoundTripMS = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildDNSQuery(name string) ([]byte, uint16, error) {
|
||||
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
|
||||
if name == "" || len(name) > 253 {
|
||||
return nil, 0, errors.New("proxy: UDP probe DNS name is invalid")
|
||||
}
|
||||
var idBytes [2]byte
|
||||
if _, err := rand.Read(idBytes[:]); err != nil {
|
||||
return nil, 0, fmt.Errorf("proxy: generate DNS probe ID: %w", err)
|
||||
}
|
||||
queryID := binary.BigEndian.Uint16(idBytes[:])
|
||||
query := make([]byte, 12, 12+len(name)+6)
|
||||
binary.BigEndian.PutUint16(query[0:2], queryID)
|
||||
binary.BigEndian.PutUint16(query[2:4], 0x0100)
|
||||
binary.BigEndian.PutUint16(query[4:6], 1)
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return nil, 0, errors.New("proxy: UDP probe DNS label is invalid")
|
||||
}
|
||||
query = append(query, byte(len(label)))
|
||||
query = append(query, label...)
|
||||
}
|
||||
query = append(query, 0, 0, 1, 0, 1)
|
||||
return query, queryID, nil
|
||||
}
|
||||
|
||||
func buildSOCKSUDPDatagram(target *net.UDPAddr, payload []byte) ([]byte, error) {
|
||||
if target == nil || target.IP == nil || target.Port < 1 || target.Port > 65535 {
|
||||
return nil, errors.New("proxy: UDP target is invalid")
|
||||
}
|
||||
packet := []byte{0, 0, 0}
|
||||
if ipv4 := target.IP.To4(); ipv4 != nil {
|
||||
packet = append(packet, 1)
|
||||
packet = append(packet, ipv4...)
|
||||
} else if ipv6 := target.IP.To16(); ipv6 != nil {
|
||||
packet = append(packet, 4)
|
||||
packet = append(packet, ipv6...)
|
||||
} else {
|
||||
return nil, errors.New("proxy: UDP target address family is invalid")
|
||||
}
|
||||
packet = append(packet, byte(target.Port>>8), byte(target.Port))
|
||||
packet = append(packet, payload...)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func parseSOCKSUDPDatagram(packet []byte) ([]byte, error) {
|
||||
if len(packet) < 4 || packet[0] != 0 || packet[1] != 0 {
|
||||
return nil, errors.New("invalid SOCKS5 UDP header")
|
||||
}
|
||||
if packet[2] != 0 {
|
||||
return nil, errors.New("fragmented SOCKS5 UDP response is unsupported")
|
||||
}
|
||||
offset := 4
|
||||
switch packet[3] {
|
||||
case 1:
|
||||
offset += net.IPv4len
|
||||
case 3:
|
||||
if len(packet) <= offset {
|
||||
return nil, errors.New("truncated SOCKS5 UDP domain")
|
||||
}
|
||||
offset += 1 + int(packet[offset])
|
||||
case 4:
|
||||
offset += net.IPv6len
|
||||
default:
|
||||
return nil, errors.New("unsupported SOCKS5 UDP address type")
|
||||
}
|
||||
if offset+2 > len(packet) {
|
||||
return nil, errors.New("truncated SOCKS5 UDP endpoint")
|
||||
}
|
||||
offset += 2
|
||||
if offset >= len(packet) {
|
||||
return nil, errors.New("empty SOCKS5 UDP payload")
|
||||
}
|
||||
return packet[offset:], nil
|
||||
}
|
||||
|
||||
func validateDNSResponse(payload []byte, queryID uint16) (int, error) {
|
||||
if len(payload) < 12 {
|
||||
return 0, errors.New("proxy: DNS response is truncated")
|
||||
}
|
||||
if binary.BigEndian.Uint16(payload[0:2]) != queryID {
|
||||
return 0, errors.New("proxy: DNS response ID does not match")
|
||||
}
|
||||
flags := binary.BigEndian.Uint16(payload[2:4])
|
||||
if flags&0x8000 == 0 {
|
||||
return 0, errors.New("proxy: DNS response is not a response")
|
||||
}
|
||||
rcode := int(flags & 0x000f)
|
||||
if rcode != 0 {
|
||||
return rcode, fmt.Errorf("proxy: DNS probe returned response code %d", rcode)
|
||||
}
|
||||
return rcode, nil
|
||||
}
|
||||
|
||||
func sameUDPAddress(left, right *net.UDPAddr) bool {
|
||||
if left == nil || right == nil || left.Port != right.Port {
|
||||
return false
|
||||
}
|
||||
if left.IP == nil || right.IP == nil {
|
||||
return true
|
||||
}
|
||||
return left.IP.Equal(right.IP)
|
||||
}
|
||||
|
||||
func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) {
|
||||
switch addressType {
|
||||
case 1:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProbeSOCKS5RequiresRealUDPExchange(t *testing.T) {
|
||||
address, stop := startProbeSOCKS5Server(t, false)
|
||||
defer stop()
|
||||
|
||||
result, err := probeSOCKS5(
|
||||
context.Background(),
|
||||
address,
|
||||
"",
|
||||
"",
|
||||
250*time.Millisecond,
|
||||
"192.0.2.53:53",
|
||||
"example.test",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("Probe unexpectedly succeeded when the relay dropped UDP data")
|
||||
}
|
||||
if !result.UDPAssociateOK {
|
||||
t.Fatal("UDP ASSOCIATE should have succeeded")
|
||||
}
|
||||
if result.UDPExchangeOK {
|
||||
t.Fatal("UDP exchange should not be reported as successful")
|
||||
}
|
||||
if result.Diagnosis != "udp_no_roundtrip" {
|
||||
t.Fatalf("Diagnosis = %q, want udp_no_roundtrip", result.Diagnosis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeSOCKS5ReportsRealUDPDNSRoundTrip(t *testing.T) {
|
||||
address, stop := startProbeSOCKS5Server(t, true)
|
||||
defer stop()
|
||||
|
||||
result, err := probeSOCKS5(
|
||||
context.Background(),
|
||||
address,
|
||||
"",
|
||||
"",
|
||||
time.Second,
|
||||
"192.0.2.53:53",
|
||||
"example.test",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Probe returned error: %v", err)
|
||||
}
|
||||
if !result.HandshakeOK || !result.UDPAssociateOK || !result.UDPExchangeOK {
|
||||
t.Fatalf("Probe evidence incomplete: %+v", result)
|
||||
}
|
||||
if result.Diagnosis != "ready" {
|
||||
t.Fatalf("Diagnosis = %q, want ready", result.Diagnosis)
|
||||
}
|
||||
if result.DNSName != "example.test" || result.DNSServer != "192.0.2.53:53" {
|
||||
t.Fatalf("Unexpected DNS evidence: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func startProbeSOCKS5Server(t *testing.T, echoDNS bool) (string, func()) {
|
||||
t.Helper()
|
||||
udpConnection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatalf("ListenUDP: %v", err)
|
||||
}
|
||||
tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
udpConnection.Close()
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
|
||||
if echoDNS {
|
||||
go func() {
|
||||
buffer := make([]byte, 2048)
|
||||
count, sender, readErr := udpConnection.ReadFromUDP(buffer)
|
||||
if readErr != nil || count < 22 {
|
||||
return
|
||||
}
|
||||
// The test target is IPv4, so the SOCKS5 UDP header is ten bytes.
|
||||
buffer[12] = 0x81
|
||||
buffer[13] = 0x80
|
||||
_, _ = udpConnection.WriteToUDP(buffer[:count], sender)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
connection, acceptErr := tcpListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
greeting := make([]byte, 3)
|
||||
if _, readErr := io.ReadFull(connection, greeting); readErr != nil {
|
||||
return
|
||||
}
|
||||
if _, writeErr := connection.Write([]byte{5, 0}); writeErr != nil {
|
||||
return
|
||||
}
|
||||
associate := make([]byte, 10)
|
||||
if _, readErr := io.ReadFull(connection, associate); readErr != nil {
|
||||
return
|
||||
}
|
||||
udpPort := udpConnection.LocalAddr().(*net.UDPAddr).Port
|
||||
response := []byte{5, 0, 0, 1, 127, 0, 0, 1, byte(udpPort >> 8), byte(udpPort)}
|
||||
if _, writeErr := connection.Write(response); writeErr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, connection)
|
||||
}()
|
||||
|
||||
return tcpListener.Addr().String(), func() {
|
||||
_ = tcpListener.Close()
|
||||
_ = udpConnection.Close()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -44,3 +53,62 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AT terminal must present ERROR / +CME ERROR as a normal response, not as
|
||||
// a 502. Before the CommandError branch was restored, every unsupported or
|
||||
// SIM-less command was folded into "the device operation failed", hiding the
|
||||
// real reason from the user.
|
||||
func TestHandleATSurfacesCommandErrorAsResponse(t *testing.T) {
|
||||
controller := fakeDeviceController{
|
||||
entry: device.Device{ID: "dev1"},
|
||||
atHandler: func(command string) (modem.Response, error) {
|
||||
return modem.Response{}, &modem.CommandError{
|
||||
Command: command,
|
||||
Final: "+CME ERROR: 10",
|
||||
Lines: []string{"+CME ERROR: 10"},
|
||||
}
|
||||
},
|
||||
}
|
||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/devices/dev1/actions/at",
|
||||
strings.NewReader(`{"cmd":"AT+CPIN?","timeout_ms":5000}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if !server.handleAT(recorder, request, "dev1") {
|
||||
t.Fatal("handleAT returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
response, _ := data["response"].(string)
|
||||
if !strings.Contains(response, "+CME ERROR: 10") {
|
||||
t.Fatalf("response = %q, want +CME ERROR text", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleATMapsNonCommandErrorTo502(t *testing.T) {
|
||||
controller := fakeDeviceController{
|
||||
entry: device.Device{ID: "dev1"},
|
||||
atErr: errors.New("transport wedged"),
|
||||
}
|
||||
server := &Server{devices: controller, logger: regionTestLogger(), maxRequestBodyBytes: 1 << 20}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/devices/dev1/actions/at",
|
||||
strings.NewReader(`{"cmd":"AT+CSQ"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if !server.handleAT(recorder, request, "dev1") {
|
||||
t.Fatal("handleAT returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
||||
}, "\n"),
|
||||
Time: run.FinishedAt, Task: task, Run: run,
|
||||
}
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
@@ -91,6 +91,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
|
||||
@@ -142,6 +142,9 @@ func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
||||
return true
|
||||
case "dashboard/host":
|
||||
s.handleDashboardHost(w, r)
|
||||
return true
|
||||
case "devices":
|
||||
return s.handleDevices(w, r)
|
||||
case "devices/discovered":
|
||||
@@ -387,6 +390,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
result = append(result, map[string]any{
|
||||
"hardware_kind": candidate.HardwareKind,
|
||||
"reader_name": candidate.ReaderName,
|
||||
"device_type": discoveredDeviceType(candidate),
|
||||
"discovery_key": entry.ID,
|
||||
"control_path": controlPath,
|
||||
"net_interface": candidate.NetworkInterface,
|
||||
@@ -1055,6 +1059,26 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
defer cancel()
|
||||
response, err := s.devices.ExecuteAT(ctx, id, command)
|
||||
if err != nil {
|
||||
var commandErr *modem.CommandError
|
||||
if errors.As(err, &commandErr) {
|
||||
// The modem answered with ERROR / +CME ERROR. An AT terminal must
|
||||
// surface that text (including the CME detail) as a normal response;
|
||||
// folding it into a 502 hides the real reason from the user.
|
||||
text := strings.Join(commandErr.Lines, "\n")
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += commandErr.Final
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"response": text,
|
||||
"final": commandErr.Final,
|
||||
"duration_ms": 0,
|
||||
"urcs": []string{},
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
@@ -1421,9 +1445,9 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
||||
default:
|
||||
// Device errors may echo an AT command. Authentication commands can
|
||||
// contain APN credentials, so keep raw errors out of logs and responses.
|
||||
s.logger.Warn("device operation failed")
|
||||
// Preserve the hardware failure reason in the operator-visible log while
|
||||
// keeping AT payloads and long APDU material out of it.
|
||||
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
|
||||
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
||||
}
|
||||
}
|
||||
@@ -1686,56 +1710,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
||||
enabled, _ := extra["enabled"].(bool)
|
||||
active, _ := extra["active"].(bool)
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"carrier_profile": extra["carrier_profile"],
|
||||
"carrier_profile_from": extra["carrier_profile_from"],
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
}
|
||||
}
|
||||
|
||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"carrier_profile": runtime.CarrierProfile,
|
||||
"carrier_profile_from": runtime.CarrierProfileFrom,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": map[string]any{
|
||||
"established": runtime.TunnelReady,
|
||||
"name": runtime.TunnelName,
|
||||
@@ -1909,6 +1937,8 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
config.NetworkEnabled = false
|
||||
config.SMSEnabled = true
|
||||
config.VoWiFiEnabled = true
|
||||
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||
config.DeviceType = store.DeviceTypeDJI4G
|
||||
}
|
||||
if config.Interface == "" {
|
||||
config.Interface = candidate.NetworkInterface
|
||||
@@ -1933,6 +1963,16 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
}
|
||||
}
|
||||
|
||||
func discoveredDeviceType(candidate modem.Candidate) string {
|
||||
if candidate.HardwareKind == "pcsc" {
|
||||
return store.DeviceTypeUSBSIMReader
|
||||
}
|
||||
if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||
return store.DeviceTypeDJI4G
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||
if snapshot == nil {
|
||||
return map[string]any{
|
||||
|
||||
@@ -6,10 +6,28 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestFillConfigFromPhysicalClassifiesDJI4G(t *testing.T) {
|
||||
config := store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}
|
||||
entry := device.Device{Candidate: modem.Candidate{
|
||||
VendorID: "2ca3",
|
||||
ProductID: "4006",
|
||||
}}
|
||||
|
||||
fillConfigFromPhysical(&config, entry)
|
||||
|
||||
if config.DeviceType != store.DeviceTypeDJI4G {
|
||||
t.Fatalf("device type = %q, want %q", config.DeviceType, store.DeviceTypeDJI4G)
|
||||
}
|
||||
if got := discoveredDeviceType(entry.Candidate); got != store.DeviceTypeDJI4G {
|
||||
t.Fatalf("discovered device type = %q, want %q", got, store.DeviceTypeDJI4G)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.store.Ready(ctx); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ready"})
|
||||
}
|
||||
|
||||
// handleMetrics exposes only process-level, non-identifying Prometheus data.
|
||||
// Device IDs, SIM identities, phone numbers and proxy information never enter
|
||||
// this unauthenticated endpoint.
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
ready := 0
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||
if s.store.Ready(ctx) == nil {
|
||||
ready = 1
|
||||
}
|
||||
cancel()
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprint(w, "# HELP vocat_up Whether the process is running.\n# TYPE vocat_up gauge\nvocat_up 1\n")
|
||||
fmt.Fprintf(w, "# HELP vocat_ready Whether the database is ready.\n# TYPE vocat_ready gauge\nvocat_ready %d\n", ready)
|
||||
fmt.Fprintf(w, "# HELP vocat_uptime_seconds Process uptime.\n# TYPE vocat_uptime_seconds counter\nvocat_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())
|
||||
fmt.Fprintf(w, "# HELP vocat_go_goroutines Current Go goroutines.\n# TYPE vocat_go_goroutines gauge\nvocat_go_goroutines %d\n", runtime.NumGoroutine())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOperationalHealthEndpointsAreAnonymousAndNonIdentifying(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
contains string
|
||||
}{
|
||||
{path: "/healthz", contentType: "application/json", contains: `"status":"ok"`},
|
||||
{path: "/readyz", contentType: "application/json", contains: `"status":"ready"`},
|
||||
{path: "/metrics", contentType: "text/plain", contains: "vocat_ready 1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
response, err := app.client.Get(app.server.URL + test.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(response.Header.Get("Content-Type"), test.contentType) {
|
||||
t.Fatalf("Content-Type = %q", response.Header.Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(string(body), test.contains) {
|
||||
t.Fatalf("body = %q, want %q", body, test.contains)
|
||||
}
|
||||
for _, forbidden := range []string{"imsi", "iccid", "msisdn", "proxy", "device_id"} {
|
||||
if strings.Contains(strings.ToLower(string(body)), forbidden) {
|
||||
t.Fatalf("body exposes forbidden label %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationalHealthEndpointsRejectPOST(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
for _, path := range []string{"/healthz", "/readyz", "/metrics"} {
|
||||
request, err := http.NewRequest(http.MethodPost, app.server.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := app.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("%s status = %d, want %d", path, response.StatusCode, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hostStaticInfo describes hardware identities that do not change while the
|
||||
// process runs, so they are probed once and cached.
|
||||
type hostStaticInfo struct {
|
||||
CPUModel string `json:"cpu_model"`
|
||||
BoardModel string `json:"board_model"`
|
||||
MemoryModel string `json:"memory_model"`
|
||||
DiskModel string `json:"disk_model"`
|
||||
}
|
||||
|
||||
// hostPerfSnapshot is one rendered read of host utilization for the dashboard.
|
||||
type hostPerfSnapshot struct {
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
MemoryUsed uint64 `json:"memory_used_bytes"`
|
||||
MemoryTotal uint64 `json:"memory_total_bytes"`
|
||||
DiskPercent float64 `json:"disk_percent"`
|
||||
DiskUsed uint64 `json:"disk_used_bytes"`
|
||||
DiskTotal uint64 `json:"disk_total_bytes"`
|
||||
NetRxBps float64 `json:"net_rx_bps"`
|
||||
NetTxBps float64 `json:"net_tx_bps"`
|
||||
}
|
||||
|
||||
// hostCPUTimes is one cumulative /proc/stat reading: idle already includes
|
||||
// iowait, total sums every other column (guest time is already folded into
|
||||
// user/nice and therefore excluded).
|
||||
type hostCPUTimes struct {
|
||||
idle uint64
|
||||
total uint64
|
||||
}
|
||||
|
||||
const (
|
||||
// hostStatsMinGap keeps back-to-back polls from dividing a handful of
|
||||
// jiffies by a few milliseconds; the previous rate is reused instead.
|
||||
hostStatsMinGap = 300 * time.Millisecond
|
||||
// hostStatsMaxGap mirrors liveNetMaxGap: a gap past this means the tab was
|
||||
// closed or the browser was hidden; re-baseline instead of averaging a
|
||||
// long dead interval.
|
||||
hostStatsMaxGap = 15 * time.Second
|
||||
// hostStatsFirstSample is how long the very first request blocks so CPU
|
||||
// and network readings have a real interval to average over. It must
|
||||
// exceed hostStatsMinGap so the re-read survives the min-gap guard below.
|
||||
hostStatsFirstSample = 400 * time.Millisecond
|
||||
)
|
||||
|
||||
// hostStatsSampler derives live host utilization from cumulative kernel
|
||||
// counters. Like liveNetTracker it is driven on demand by dashboard polling,
|
||||
// so no background goroutine is required.
|
||||
type hostStatsSampler struct {
|
||||
mu sync.Mutex
|
||||
static *hostStaticInfo
|
||||
sampledAt time.Time
|
||||
prevCPU hostCPUTimes
|
||||
prevNetRx uint64
|
||||
prevNetTx uint64
|
||||
lastCPU float64
|
||||
lastRxBps float64
|
||||
lastTxBps float64
|
||||
}
|
||||
|
||||
func newHostStatsSampler() *hostStatsSampler {
|
||||
return &hostStatsSampler{}
|
||||
}
|
||||
|
||||
// handleDashboardHost serves the dashboard host card: static hardware identity
|
||||
// plus live utilization. Both halves are cheap reads of /proc and /sys.
|
||||
func (s *Server) handleDashboardHost(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"host": s.hostStats.info(),
|
||||
"perf": s.hostStats.perf(),
|
||||
}})
|
||||
}
|
||||
|
||||
// info returns the cached static hardware description, probing it on first use.
|
||||
func (s *hostStatsSampler) info() hostStaticInfo {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.static == nil {
|
||||
info := probeHostStatic()
|
||||
s.static = &info
|
||||
}
|
||||
return *s.static
|
||||
}
|
||||
|
||||
// perf renders one utilization snapshot. CPU and network rates need a baseline,
|
||||
// so the first-ever call takes a short inline second reading; later calls
|
||||
// average against the previous request.
|
||||
func (s *hostStatsSampler) perf() hostPerfSnapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cpu, cpuOK := readHostCPUTimes()
|
||||
rx, tx, netOK := readHostNetTotals()
|
||||
|
||||
// No usable baseline yet (first request, or the tab was hidden past the
|
||||
// max gap): establish one, then re-read after a short interval so the
|
||||
// first dashboard paint already reports real numbers.
|
||||
needBaseline := s.sampledAt.IsZero() || now.Sub(s.sampledAt) > hostStatsMaxGap
|
||||
if needBaseline && (cpuOK || netOK) {
|
||||
s.sampledAt = now
|
||||
if cpuOK {
|
||||
s.prevCPU = cpu
|
||||
}
|
||||
if netOK {
|
||||
s.prevNetRx, s.prevNetTx = rx, tx
|
||||
}
|
||||
time.Sleep(hostStatsFirstSample)
|
||||
now = time.Now()
|
||||
if next, ok := readHostCPUTimes(); ok {
|
||||
cpu, cpuOK = next, true
|
||||
}
|
||||
if nextRx, nextTx, ok := readHostNetTotals(); ok {
|
||||
rx, tx, netOK = nextRx, nextTx, true
|
||||
}
|
||||
}
|
||||
|
||||
memPercent, memUsed, memTotal := readHostMemory()
|
||||
diskPercent, diskUsed, diskTotal := readHostDisk()
|
||||
|
||||
gap := now.Sub(s.sampledAt)
|
||||
if gap >= hostStatsMinGap && (cpuOK || netOK) {
|
||||
if cpuOK {
|
||||
if busyDelta, totalDelta := cpuDelta(s.prevCPU, cpu); totalDelta > 0 {
|
||||
s.lastCPU = clampPercent(float64(busyDelta) * 100 / float64(totalDelta))
|
||||
}
|
||||
s.prevCPU = cpu
|
||||
}
|
||||
if netOK {
|
||||
// Counter resets (interface flap) must not produce a giant spike.
|
||||
if rx >= s.prevNetRx {
|
||||
s.lastRxBps = float64(rx-s.prevNetRx) / gap.Seconds()
|
||||
} else {
|
||||
s.lastRxBps = 0
|
||||
}
|
||||
if tx >= s.prevNetTx {
|
||||
s.lastTxBps = float64(tx-s.prevNetTx) / gap.Seconds()
|
||||
} else {
|
||||
s.lastTxBps = 0
|
||||
}
|
||||
s.prevNetRx, s.prevNetTx = rx, tx
|
||||
}
|
||||
s.sampledAt = now
|
||||
}
|
||||
|
||||
return hostPerfSnapshot{
|
||||
CPUPercent: s.lastCPU,
|
||||
MemoryPercent: memPercent,
|
||||
MemoryUsed: memUsed,
|
||||
MemoryTotal: memTotal,
|
||||
DiskPercent: diskPercent,
|
||||
DiskUsed: diskUsed,
|
||||
DiskTotal: diskTotal,
|
||||
NetRxBps: s.lastRxBps,
|
||||
NetTxBps: s.lastTxBps,
|
||||
}
|
||||
}
|
||||
|
||||
// cpuDelta returns the busy and total jiffies elapsed between two cumulative
|
||||
// readings. A backwards counter (theoretically impossible for /proc/stat)
|
||||
// reports zero rather than wrapping.
|
||||
func cpuDelta(prev, next hostCPUTimes) (busy, total uint64) {
|
||||
if next.total <= prev.total || next.idle < prev.idle {
|
||||
return 0, 0
|
||||
}
|
||||
totalDelta := next.total - prev.total
|
||||
idleDelta := next.idle - prev.idle
|
||||
if idleDelta >= totalDelta {
|
||||
return 0, totalDelta
|
||||
}
|
||||
return totalDelta - idleDelta, totalDelta
|
||||
}
|
||||
|
||||
func clampPercent(value float64) float64 {
|
||||
switch {
|
||||
case value < 0:
|
||||
return 0
|
||||
case value > 100:
|
||||
return 100
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// hostNetIgnoredPrefixes are virtual interface name prefixes whose counters
|
||||
// would double-count physical traffic (bridges, tunnels, vocat's own links) or
|
||||
// carry no real host traffic at all.
|
||||
var hostNetIgnoredPrefixes = []string{
|
||||
"lo", "br-", "docker", "veth", "virbr", "vmnet", "vboxnet",
|
||||
"ip6tnl", "ip6gre", "sit", "gre", "gretap", "erspan",
|
||||
"tun", "tap", "utun", "vocat", "wg", "zt", "tailscale",
|
||||
"ifb", "bond", "vlan", "macvlan", "dummy", "lxc", "cali", "flannel", "cni",
|
||||
}
|
||||
|
||||
// hostNetInterfaceCounted reports whether an interface's byte counters feed the
|
||||
// host-level upload/download rates.
|
||||
func hostNetInterfaceCounted(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range hostNetIgnoredPrefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseNetDevCounters sums rx/tx bytes across counted interfaces in
|
||||
// /proc/net/dev content.
|
||||
func parseNetDevCounters(content string) (rx, tx uint64) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
name, rest, found := strings.Cut(line, ":")
|
||||
if !found || !hostNetInterfaceCounted(name) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
rxBytes, okRx := parseUint(fields[0])
|
||||
txBytes, okTx := parseUint(fields[8])
|
||||
if !okRx || !okTx {
|
||||
continue
|
||||
}
|
||||
rx += rxBytes
|
||||
tx += txBytes
|
||||
}
|
||||
return rx, tx
|
||||
}
|
||||
|
||||
func parseUint(text string) (uint64, bool) {
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(text), 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
// parseCPUTimes parses the aggregate "cpu" line of /proc/stat.
|
||||
func parseCPUTimes(line string) (hostCPUTimes, bool) {
|
||||
fields := strings.Fields(line)
|
||||
// cpu user nice system idle iowait irq softirq steal [guest guest_nice]
|
||||
if len(fields) < 9 || fields[0] != "cpu" {
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
var times hostCPUTimes
|
||||
for index, field := range fields[1:9] {
|
||||
value, ok := parseUint(field)
|
||||
if !ok {
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
times.total += value
|
||||
if index == 3 || index == 4 { // idle + iowait
|
||||
times.idle += value
|
||||
}
|
||||
}
|
||||
return times, true
|
||||
}
|
||||
|
||||
// parseMeminfo extracts MemTotal and MemAvailable (bytes) from /proc/meminfo.
|
||||
func parseMeminfo(content string) (total, available uint64, ok bool) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, rest, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
var value uint64
|
||||
switch strings.TrimSpace(key) {
|
||||
case "MemTotal":
|
||||
value, ok = parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB")))
|
||||
if ok {
|
||||
total = value * 1024
|
||||
}
|
||||
case "MemAvailable":
|
||||
if value, parsed := parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB"))); parsed {
|
||||
available = value * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
return total, available, total > 0
|
||||
}
|
||||
|
||||
// parseCPUInfoModel returns the x86-style "model name" from /proc/cpuinfo, or
|
||||
// an empty string on ARM hosts that only carry CPU part numbers.
|
||||
func parseCPUInfoModel(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "model name", "Model", "Hardware":
|
||||
if model := strings.TrimSpace(value); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseCPUInfoPart returns the first ARM "CPU part" hex identifier (e.g.
|
||||
// 0xd03) and the number of processors listed.
|
||||
func parseCPUInfoPart(content string) (part string, processors int) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "processor":
|
||||
processors++
|
||||
case "CPU part":
|
||||
if part == "" {
|
||||
part = strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
return part, processors
|
||||
}
|
||||
|
||||
// armCPUPartNames maps ARM CPU part identifiers to marketing core names.
|
||||
var armCPUPartNames = map[string]string{
|
||||
"0xd03": "Cortex-A53",
|
||||
"0xd04": "Cortex-A35",
|
||||
"0xd05": "Cortex-A55",
|
||||
"0xd06": "Cortex-A65",
|
||||
"0xd07": "Cortex-A57",
|
||||
"0xd08": "Cortex-A72",
|
||||
"0xd09": "Cortex-A73",
|
||||
"0xd0a": "Cortex-A75",
|
||||
"0xd0b": "Cortex-A76",
|
||||
"0xd0c": "Neoverse-N1",
|
||||
"0xd0d": "Cortex-A77",
|
||||
"0xd0e": "Cortex-A76AE",
|
||||
"0xd40": "Neoverse-V1",
|
||||
"0xd41": "Cortex-A78",
|
||||
"0xd42": "Cortex-A78AE",
|
||||
"0xd44": "Cortex-X1",
|
||||
"0xd46": "Cortex-A510",
|
||||
"0xd47": "Cortex-A710",
|
||||
"0xd48": "Cortex-X2",
|
||||
"0xd4b": "Cortex-A715",
|
||||
"0xd4d": "Cortex-A520",
|
||||
"0xd4e": "Cortex-X3",
|
||||
}
|
||||
|
||||
// socVendorNames prettifies the vendor half of a device-tree compatible entry.
|
||||
var socVendorNames = map[string]string{
|
||||
"allwinner": "Allwinner",
|
||||
"amlogic": "Amlogic",
|
||||
"broadcom": "Broadcom",
|
||||
"mediatek": "MediaTek",
|
||||
"nvidia": "NVIDIA",
|
||||
"qualcomm": "Qualcomm",
|
||||
"raspberrypi": "Raspberry Pi",
|
||||
"rockchip": "Rockchip",
|
||||
"samsung": "Samsung",
|
||||
"ti": "TI",
|
||||
"xunlong": "Xunlong",
|
||||
}
|
||||
|
||||
// parseCompatibleSoC extracts the SoC half of a device-tree compatible list
|
||||
// (NUL-separated, most specific first): "xunlong,orangepi-zero3\0allwinner,
|
||||
// sun50i-h618\0" yields "Allwinner sun50i-h618".
|
||||
func parseCompatibleSoC(raw string) string {
|
||||
entries := strings.FieldsFunc(raw, func(r rune) bool { return r == 0 || r == '\n' })
|
||||
// The last entry is the least specific compatible, which on ARM boards is
|
||||
// the SoC rather than the board.
|
||||
for index := len(entries) - 1; index >= 0; index-- {
|
||||
entry := strings.TrimSpace(entries[index])
|
||||
vendor, soc, found := strings.Cut(entry, ",")
|
||||
if !found || soc == "" {
|
||||
continue
|
||||
}
|
||||
if pretty, ok := socVendorNames[strings.ToLower(vendor)]; ok {
|
||||
vendor = pretty
|
||||
} else {
|
||||
vendor = strings.ToUpper(vendor[:1]) + vendor[1:]
|
||||
}
|
||||
return vendor + " " + soc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// composeARMCPUModel renders e.g. "Allwinner sun50i-h618 · 4× Cortex-A53".
|
||||
func composeARMCPUModel(soc, part string, processors int) string {
|
||||
core := armCPUPartNames[part]
|
||||
var result string
|
||||
switch {
|
||||
case soc != "" && core != "" && processors > 0:
|
||||
result = soc + " · " + strconv.Itoa(processors) + "× " + core
|
||||
case soc != "" && processors > 0:
|
||||
result = soc + " · " + strconv.Itoa(processors) + "× CPU"
|
||||
case soc != "" && core != "":
|
||||
result = soc + " · " + core
|
||||
default:
|
||||
result = soc
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// skipHostDisk reports whether a /sys/block entry is a virtual device whose
|
||||
// "model" would only clutter the host card.
|
||||
func skipHostDisk(name string) bool {
|
||||
for _, prefix := range []string{"loop", "ram", "zram", "sr", "nbd", "dm-", "md", "mtdblock", "ubi", "ubiblock"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseDmidecodeMemory extracts a compact "8 GB DDR4 M471A1K43CB1-CRC" style
|
||||
// description from `dmidecode -t 17` output, preferring the first populated
|
||||
// slot. Empty when no installed module can be described.
|
||||
func parseDmidecodeMemory(output string) string {
|
||||
var size, memType, partNumber string
|
||||
flush := func() string {
|
||||
if size != "" && partNumber != "" {
|
||||
return strings.TrimSpace(size + " " + memType + " " + partNumber)
|
||||
}
|
||||
if size != "" && memType != "" {
|
||||
return strings.TrimSpace(size + " " + memType)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Memory Device") {
|
||||
if composed := flush(); composed != "" {
|
||||
return composed
|
||||
}
|
||||
size, memType, partNumber = "", "", ""
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(trimmed, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "Size":
|
||||
if !strings.Contains(value, "No Module") && value != "" && value != "Unknown" {
|
||||
size = value
|
||||
}
|
||||
case "Type":
|
||||
if value != "Unknown" && value != "Other" && !strings.HasPrefix(value, "<OUT OF SPEC") {
|
||||
memType = value
|
||||
}
|
||||
case "Part Number":
|
||||
if value != "Unknown" && value != "None" && value != "" {
|
||||
partNumber = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return flush()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//go:build linux
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// probeHostStatic gathers the hardware identities shown on the dashboard host
|
||||
// card. Every probe is best-effort: empty fields render as "—" in the SPA.
|
||||
func probeHostStatic() hostStaticInfo {
|
||||
return hostStaticInfo{
|
||||
CPUModel: readHostCPUModel(),
|
||||
BoardModel: readHostBoardModel(),
|
||||
MemoryModel: readHostMemoryModel(),
|
||||
DiskModel: readHostDiskModel(),
|
||||
}
|
||||
}
|
||||
|
||||
// readHostCPUModel prefers the x86-style "model name"; on ARM hosts it composes
|
||||
// the device-tree SoC with the core count and Cortex part name.
|
||||
func readHostCPUModel() string {
|
||||
cpuinfo, err := os.ReadFile("/proc/cpuinfo")
|
||||
if err == nil {
|
||||
if model := parseCPUInfoModel(string(cpuinfo)); model != "" {
|
||||
return model
|
||||
}
|
||||
part, processors := parseCPUInfoPart(string(cpuinfo))
|
||||
if processors == 0 {
|
||||
processors = runtime.NumCPU()
|
||||
}
|
||||
soc := ""
|
||||
if compatible, err := os.ReadFile("/proc/device-tree/compatible"); err == nil {
|
||||
soc = parseCompatibleSoC(string(compatible))
|
||||
}
|
||||
if model := composeARMCPUModel(soc, part, processors); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
return runtime.GOARCH
|
||||
}
|
||||
|
||||
// readHostBoardModel reads the device-tree model on ARM boards and the DMI
|
||||
// board name on x86 machines.
|
||||
func readHostBoardModel() string {
|
||||
if model, err := os.ReadFile("/proc/device-tree/model"); err == nil {
|
||||
if text := strings.TrimSpace(strings.TrimRight(string(model), "\x00")); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
dmiDir := "/sys/devices/virtual/dmi/id"
|
||||
board := readSysfsTrimmed(filepath.Join(dmiDir, "board_name"))
|
||||
vendor := readSysfsTrimmed(filepath.Join(dmiDir, "board_vendor"))
|
||||
if board != "" && !isPlaceholderDMI(board) {
|
||||
if vendor != "" && !isPlaceholderDMI(vendor) && !strings.Contains(strings.ToLower(board), strings.ToLower(vendor)) {
|
||||
return vendor + " " + board
|
||||
}
|
||||
return board
|
||||
}
|
||||
if product := readSysfsTrimmed(filepath.Join(dmiDir, "product_name")); product != "" && !isPlaceholderDMI(product) {
|
||||
return product
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isPlaceholderDMI filters the well-known "we never filled this in" DMI
|
||||
// strings so they do not surface as board models.
|
||||
func isPlaceholderDMI(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "default string", "to be filled by o.e.m.", "to be filled by o.e.m", "none", "unknown", "n/a", "not specified", "system manufacturer":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readHostMemoryModel reports the installed DIMM description when dmidecode is
|
||||
// available (typical on x86 NAS/PC hosts) and falls back to total capacity,
|
||||
// which is all an ARM board exposes.
|
||||
func readHostMemoryModel() string {
|
||||
if path, err := exec.LookPath("dmidecode"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if output, err := exec.CommandContext(ctx, path, "-t", "17").Output(); err == nil {
|
||||
if model := parseDmidecodeMemory(string(output)); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
if total, _, ok := readHostMemoryBytes(); ok {
|
||||
return formatLiveBytes(float64(total))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readHostDiskModel describes physical block devices, skipping virtual ones
|
||||
// (loop, zram, device-mapper, mtd, optical). Multiple disks join with "; ".
|
||||
func readHostDiskModel() string {
|
||||
entries, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var disks []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if skipHostDisk(name) {
|
||||
continue
|
||||
}
|
||||
base := filepath.Join("/sys/block", name)
|
||||
sizeText := readSysfsTrimmed(filepath.Join(base, "size"))
|
||||
sectors, ok := parseUint(sizeText)
|
||||
if !ok || sectors == 0 {
|
||||
// An empty card reader reports size 0 and tells us nothing.
|
||||
continue
|
||||
}
|
||||
model := readSysfsTrimmed(filepath.Join(base, "device", "model"))
|
||||
if model == "" {
|
||||
// MMC/SD cards carry the product name instead of a model string.
|
||||
model = readSysfsTrimmed(filepath.Join(base, "device", "name"))
|
||||
}
|
||||
if model == "" {
|
||||
model = name
|
||||
}
|
||||
capacity := formatLiveBytes(float64(sectors) * 512)
|
||||
disks = append(disks, model+" · "+capacity)
|
||||
}
|
||||
return strings.Join(disks, "; ")
|
||||
}
|
||||
|
||||
func readSysfsTrimmed(path string) string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(raw), "\x00"))
|
||||
}
|
||||
|
||||
// readHostCPUTimes reads the aggregate counters from /proc/stat.
|
||||
func readHostCPUTimes() (hostCPUTimes, bool) {
|
||||
raw, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(line, "cpu ") {
|
||||
return parseCPUTimes(line)
|
||||
}
|
||||
}
|
||||
return hostCPUTimes{}, false
|
||||
}
|
||||
|
||||
// readHostMemoryBytes returns MemTotal and MemAvailable in bytes.
|
||||
func readHostMemoryBytes() (total, available uint64, ok bool) {
|
||||
raw, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return parseMeminfo(string(raw))
|
||||
}
|
||||
|
||||
// readHostMemory reports used/total bytes and the used percentage.
|
||||
func readHostMemory() (percent float64, used, total uint64) {
|
||||
total, available, ok := readHostMemoryBytes()
|
||||
if !ok || total == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
used = total - available
|
||||
return clampPercent(float64(used) * 100 / float64(total)), used, total
|
||||
}
|
||||
|
||||
// readHostDisk reports root filesystem usage the way df does: usable space is
|
||||
// total minus reserved blocks, and the percentage is used/(used+available).
|
||||
func readHostDisk() (percent float64, used, total uint64) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs("/", &stat); err != nil || stat.Blocks == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
blockSize := uint64(stat.Bsize)
|
||||
total = stat.Blocks * blockSize
|
||||
free := stat.Bfree * blockSize
|
||||
available := stat.Bavail * blockSize
|
||||
used = total - free
|
||||
if denominator := used + available; denominator > 0 {
|
||||
percent = clampPercent(float64(used) * 100 / float64(denominator))
|
||||
}
|
||||
return percent, used, total
|
||||
}
|
||||
|
||||
// readHostNetTotals sums rx/tx counters across physical host interfaces.
|
||||
func readHostNetTotals() (rx, tx uint64, ok bool) {
|
||||
raw, err := os.ReadFile("/proc/net/dev")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
rx, tx = parseNetDevCounters(string(raw))
|
||||
return rx, tx, true
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !linux
|
||||
|
||||
package server
|
||||
|
||||
// Host statistics are only meaningful on the Linux deployment target; on other
|
||||
// platforms every probe reports empty/zero and the dashboard renders "—".
|
||||
|
||||
func probeHostStatic() hostStaticInfo { return hostStaticInfo{} }
|
||||
|
||||
func readHostCPUTimes() (hostCPUTimes, bool) { return hostCPUTimes{}, false }
|
||||
|
||||
func readHostNetTotals() (uint64, uint64, bool) { return 0, 0, false }
|
||||
|
||||
func readHostMemory() (float64, uint64, uint64) { return 0, 0, 0 }
|
||||
|
||||
func readHostDisk() (float64, uint64, uint64) { return 0, 0, 0 }
|
||||
@@ -0,0 +1,184 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCPUTimes(t *testing.T) {
|
||||
times, ok := parseCPUTimes("cpu 38073 0 24013 6762971 3121 0 3019 0 0 0")
|
||||
if !ok {
|
||||
t.Fatal("parseCPUTimes rejected a valid cpu line")
|
||||
}
|
||||
wantTotal := uint64(38073 + 0 + 24013 + 6762971 + 3121 + 0 + 3019 + 0)
|
||||
if times.total != wantTotal {
|
||||
t.Fatalf("total = %d, want %d", times.total, wantTotal)
|
||||
}
|
||||
if wantIdle := uint64(6762971 + 3121); times.idle != wantIdle {
|
||||
t.Fatalf("idle = %d, want %d", times.idle, wantIdle)
|
||||
}
|
||||
if _, ok := parseCPUTimes("cpu0 1 2 3 4 5 6 7 8"); ok {
|
||||
t.Fatal("per-core line must not parse as the aggregate line")
|
||||
}
|
||||
if _, ok := parseCPUTimes("cpu 1 2 3"); ok {
|
||||
t.Fatal("truncated cpu line must not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUDelta(t *testing.T) {
|
||||
prev := hostCPUTimes{idle: 100, total: 200}
|
||||
next := hostCPUTimes{idle: 150, total: 300}
|
||||
busy, total := cpuDelta(prev, next)
|
||||
if busy != 50 || total != 100 {
|
||||
t.Fatalf("cpuDelta = (%d, %d), want (50, 100)", busy, total)
|
||||
}
|
||||
if busy, total := cpuDelta(next, prev); busy != 0 || total != 0 {
|
||||
t.Fatalf("backwards counters must report zero, got (%d, %d)", busy, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMeminfo(t *testing.T) {
|
||||
content := "MemTotal: 2040424 kB\nMemFree: 920864 kB\nMemAvailable: 1543480 kB\nBuffers: 315908 kB\n"
|
||||
total, available, ok := parseMeminfo(content)
|
||||
if !ok {
|
||||
t.Fatal("parseMeminfo rejected valid content")
|
||||
}
|
||||
if total != 2040424*1024 {
|
||||
t.Fatalf("total = %d, want %d", total, 2040424*1024)
|
||||
}
|
||||
if available != 1543480*1024 {
|
||||
t.Fatalf("available = %d, want %d", available, 1543480*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetDevCounters(t *testing.T) {
|
||||
content := `Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
lo: 10 1 0 0 0 0 0 0 20 2 0 0 0 0 0 0
|
||||
eth0: 100 1 0 0 0 0 0 0 200 2 0 0 0 0 0 0
|
||||
br-lan: 1000 1 0 0 0 0 0 0 2000 2 0 0 0 0 0 0
|
||||
utun: 300 1 0 0 0 0 0 0 400 2 0 0 0 0 0 0
|
||||
vocat50a684ceb0: 500 1 0 0 0 0 0 0 600 2 0 0 0 0 0 0
|
||||
wwan0: 700 1 0 0 0 0 0 0 800 2 0 0 0 0 0 0
|
||||
`
|
||||
rx, tx := parseNetDevCounters(content)
|
||||
// Only eth0 and wwan0 count; lo, br-lan, utun and vocat are virtual.
|
||||
if rx != 800 || tx != 1000 {
|
||||
t.Fatalf("rx,tx = %d,%d, want 800,1000", rx, tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostNetInterfaceCounted(t *testing.T) {
|
||||
counted := []string{"eth0", "eth1", "wwan0", "usb0", "wlan0", "enp3s0", "pppoe-wan"}
|
||||
for _, name := range counted {
|
||||
if !hostNetInterfaceCounted(name) {
|
||||
t.Fatalf("%s should be counted", name)
|
||||
}
|
||||
}
|
||||
skipped := []string{"lo", "br-lan", "docker0", "veth123", "ip6tnl0", "sit0", "utun", "vocat50a684ceb0", "wg0", "tun0", "tailscale0", ""}
|
||||
for _, name := range skipped {
|
||||
if hostNetInterfaceCounted(name) {
|
||||
t.Fatalf("%s should be skipped", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUInfoModelX86(t *testing.T) {
|
||||
content := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz\n"
|
||||
if model := parseCPUInfoModel(content); model != "Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz" {
|
||||
t.Fatalf("model = %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUInfoARM(t *testing.T) {
|
||||
content := "processor\t: 0\nBogoMIPS\t: 48.00\nCPU implementer\t: 0x41\nCPU part\t: 0xd03\nprocessor\t: 1\nCPU part\t: 0xd03\n"
|
||||
if model := parseCPUInfoModel(content); model != "" {
|
||||
t.Fatalf("ARM cpuinfo must not report an x86 model name, got %q", model)
|
||||
}
|
||||
part, processors := parseCPUInfoPart(content)
|
||||
if part != "0xd03" || processors != 2 {
|
||||
t.Fatalf("part,processors = %q,%d, want 0xd03,2", part, processors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCompatibleSoC(t *testing.T) {
|
||||
raw := "xunlong,orangepi-zero3\x00allwinner,sun50i-h618\x00"
|
||||
if soc := parseCompatibleSoC(raw); soc != "Allwinner sun50i-h618" {
|
||||
t.Fatalf("soc = %q", soc)
|
||||
}
|
||||
if soc := parseCompatibleSoC(""); soc != "" {
|
||||
t.Fatalf("empty compatible must yield empty soc, got %q", soc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeARMCPUModel(t *testing.T) {
|
||||
model := composeARMCPUModel("Allwinner sun50i-h618", "0xd03", 4)
|
||||
if model != "Allwinner sun50i-h618 · 4× Cortex-A53" {
|
||||
t.Fatalf("model = %q", model)
|
||||
}
|
||||
if model := composeARMCPUModel("", "", 0); model != "" {
|
||||
t.Fatalf("empty inputs must yield empty model, got %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDmidecodeMemory(t *testing.T) {
|
||||
output := `# dmidecode 3.3
|
||||
Getting SMBIOS data from sysfs.
|
||||
SMBIOS 3.0 present.
|
||||
|
||||
Handle 0x0010, DMI type 17, 40 bytes
|
||||
Memory Device
|
||||
Array Handle: 0x000F
|
||||
Error Information Handle: Not Provided
|
||||
Total Width: 64 bits
|
||||
Data Width: 64 bits
|
||||
Size: 8 GB
|
||||
Form Factor: SODIMM
|
||||
Type: DDR4
|
||||
Speed: 2400 MT/s
|
||||
Manufacturer: Samsung
|
||||
Serial Number: 12345678
|
||||
Part Number: M471A1K43CB1-CRC
|
||||
Rank: 1
|
||||
|
||||
Handle 0x0011, DMI type 17, 40 bytes
|
||||
Memory Device
|
||||
Size: No Module Installed
|
||||
Type: Unknown
|
||||
`
|
||||
if model := parseDmidecodeMemory(output); model != "8 GB DDR4 M471A1K43CB1-CRC" {
|
||||
t.Fatalf("model = %q", model)
|
||||
}
|
||||
if model := parseDmidecodeMemory("Memory Device\n\tSize: No Module Installed\n"); model != "" {
|
||||
t.Fatalf("unpopulated slots must yield empty model, got %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampPercent(t *testing.T) {
|
||||
if clampPercent(-1) != 0 || clampPercent(101) != 100 || clampPercent(50) != 50 {
|
||||
t.Fatal("clampPercent bounds violated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUintTrims(t *testing.T) {
|
||||
if value, ok := parseUint(" 61440000 "); !ok || value != 61440000 {
|
||||
t.Fatalf("parseUint = %d,%v", value, ok)
|
||||
}
|
||||
if _, ok := parseUint("not-a-number"); ok {
|
||||
t.Fatal("parseUint accepted garbage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipHostDiskPrefixes(t *testing.T) {
|
||||
skipped := []string{"loop0", "ram0", "zram0", "sr0", "nbd0", "dm-0", "md0", "mtdblock0", "ubiblock0_0"}
|
||||
for _, name := range skipped {
|
||||
if !skipHostDisk(name) {
|
||||
t.Fatalf("%s should be skipped", name)
|
||||
}
|
||||
}
|
||||
kept := []string{"sda", "nvme0n1", "mmcblk0", "vda", "sdb"}
|
||||
for _, name := range kept {
|
||||
if skipHostDisk(name) {
|
||||
t.Fatalf("%s should be kept", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxLarkPayloadBytes = 20 << 10
|
||||
|
||||
var larkTemplateVariableNames = []string{
|
||||
"event",
|
||||
"title",
|
||||
"message",
|
||||
"timestamp",
|
||||
"content",
|
||||
"number",
|
||||
"device_id",
|
||||
"device_name",
|
||||
"device_label",
|
||||
"time",
|
||||
}
|
||||
|
||||
var larkTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^{}]*\}\}`)
|
||||
|
||||
var larkWebhookHosts = map[string]struct{}{
|
||||
"open.feishu.cn": {},
|
||||
"open.larksuite.com": {},
|
||||
}
|
||||
|
||||
type larkTemplateValues map[string]string
|
||||
|
||||
func renderLarkPayload(template string, values larkTemplateValues) ([]byte, error) {
|
||||
encodedValues := make(map[string]string, len(larkTemplateVariableNames))
|
||||
for _, name := range larkTemplateVariableNames {
|
||||
encoded, err := json.Marshal(values[name])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Lark template value %q: %w", name, err)
|
||||
}
|
||||
encodedValues[name] = string(encoded)
|
||||
}
|
||||
unsupported := false
|
||||
rendered := larkTemplatePlaceholderPattern.ReplaceAllStringFunc(template, func(placeholder string) string {
|
||||
name := placeholder[2 : len(placeholder)-2]
|
||||
encoded, ok := encodedValues[name]
|
||||
if !ok {
|
||||
unsupported = true
|
||||
return placeholder
|
||||
}
|
||||
return encoded
|
||||
})
|
||||
remainder := larkTemplatePlaceholderPattern.ReplaceAllString(template, "")
|
||||
if unsupported || strings.Contains(remainder, "{{") {
|
||||
return nil, errors.New("lark.payload_template contains an unsupported variable")
|
||||
}
|
||||
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rendered), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("lark.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
if len(rendered) > maxLarkPayloadBytes {
|
||||
return nil, errors.New("lark.payload_template renders beyond the 20 KB Lark limit")
|
||||
}
|
||||
return []byte(rendered), nil
|
||||
}
|
||||
|
||||
func larkSignature(timestamp int64, secret string) string {
|
||||
key := strconv.FormatInt(timestamp, 10) + "\n" + secret
|
||||
signature := hmac.New(sha256.New, []byte(key))
|
||||
return base64.StdEncoding.EncodeToString(signature.Sum(nil))
|
||||
}
|
||||
|
||||
func signLarkPayload(payload []byte, secret string, now time.Time) ([]byte, error) {
|
||||
if secret == "" {
|
||||
return payload, nil
|
||||
}
|
||||
var document map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &document); err != nil || len(document) == 0 {
|
||||
return nil, errors.New("lark payload must be a non-empty JSON object")
|
||||
}
|
||||
timestamp := now.Unix()
|
||||
document["timestamp"], _ = json.Marshal(strconv.FormatInt(timestamp, 10))
|
||||
document["sign"], _ = json.Marshal(larkSignature(timestamp, secret))
|
||||
signed, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode signed Lark payload: %w", err)
|
||||
}
|
||||
if len(signed) > maxLarkPayloadBytes {
|
||||
return nil, errors.New("lark payload exceeds the 20 KB Lark limit after signing")
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func validateLarkResponse(status int, body []byte) error {
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
StatusCode *int `json:"StatusCode"`
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil {
|
||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||
}
|
||||
if result.Code != nil {
|
||||
if *result.Code == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||
}
|
||||
if result.StatusCode == nil || *result.StatusCode != 0 {
|
||||
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok {
|
||||
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
|
||||
}
|
||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||
return nil, errors.New("Lark group bot webhook must use the default HTTPS port")
|
||||
}
|
||||
const prefix = "/open-apis/bot/v2/hook/"
|
||||
token := strings.TrimPrefix(parsed.Path, prefix)
|
||||
if token == parsed.Path || token == "" || strings.Contains(token, "/") || parsed.RawPath != "" ||
|
||||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
||||
return nil, errors.New("Lark group bot webhook path is invalid")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseLarkWebhookURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func larkTestValues(now time.Time) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func larkSMSValues(message smsNotification) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "sms.received",
|
||||
"title": "收到新短信",
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.Content,
|
||||
"number": message.Number,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "automatic_task.completed",
|
||||
"title": message.Title,
|
||||
"message": message.Text,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": "",
|
||||
"number": "",
|
||||
"device_id": "",
|
||||
"device_name": "",
|
||||
"device_label": "",
|
||||
"time": "",
|
||||
}
|
||||
}
|
||||
|
||||
func validateLarkNotificationConfig(config map[string]any) error {
|
||||
if configString(config, "url") == "" {
|
||||
return errors.New("lark.url is required")
|
||||
}
|
||||
template := configString(config, "payload_template")
|
||||
if template == "" {
|
||||
return errors.New("lark.payload_template is required")
|
||||
}
|
||||
if signingEnabled, _ := config["signing_enabled"].(bool); signingEnabled {
|
||||
secret := configString(config, "secret")
|
||||
if secret == "" {
|
||||
return errors.New("lark.secret is required when signing is enabled")
|
||||
}
|
||||
}
|
||||
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
|
||||
return err
|
||||
}
|
||||
|
||||
func larkSigningSecret(config map[string]any) string {
|
||||
enabled, _ := config["signing_enabled"].(bool)
|
||||
if !enabled {
|
||||
return ""
|
||||
}
|
||||
return configString(config, "secret")
|
||||
}
|
||||
|
||||
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
|
||||
if err := validateLarkNotificationConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err = signLarkPayload(payload, larkSigningSecret(config), time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := validateLarkWebhookURL(ctx, configString(config, "url"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return postLarkNotification(ctx, client, parsed.String(), payload)
|
||||
}
|
||||
|
||||
func postLarkNotification(ctx context.Context, client *http.Client, endpoint string, payload []byte) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Lark notification request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-lark-notification/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
closeErr := response.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read Lark response: %w", readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close Lark response: %w", closeErr)
|
||||
}
|
||||
if err := validateLarkResponse(response.StatusCode, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeLarkRequestError(err error) error {
|
||||
var requestErr *url.Error
|
||||
if errors.As(err, &requestErr) && requestErr.Err != nil {
|
||||
return requestErr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func sendLarkNotificationTest(ctx context.Context, config map[string]any) error {
|
||||
return sendLarkNotification(ctx, config, larkTestValues(time.Now()))
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRenderLarkPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderLarkPayload(
|
||||
`{"msg_type":"text","content":{"text":{{message}},"number":{{number}}}}`,
|
||||
larkTemplateValues{
|
||||
"message": "quote: \"\nline",
|
||||
"number": "+447386",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(payload), `{"msg_type":"text","content":{"text":"quote: \"\nline","number":"+447386"}}`; got != want {
|
||||
t.Fatalf("payload = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLarkPayloadDoesNotInterpretPlaceholdersInsideValues(t *testing.T) {
|
||||
payload, err := renderLarkPayload(
|
||||
`{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
larkTemplateValues{"message": "keep {{timestamp}} literally", "timestamp": "changed"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(payload), `{"msg_type":"text","content":{"text":"keep {{timestamp}} literally"}}`; got != want {
|
||||
t.Fatalf("payload = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLarkPayloadRejectsInvalidTemplate(t *testing.T) {
|
||||
for _, template := range []string{
|
||||
`{"text":{{unknown}}}`,
|
||||
`[]`,
|
||||
`{"msg_type":"text"`,
|
||||
`{"text":"` + strings.Repeat("x", maxLarkPayloadBytes) + `"}`,
|
||||
} {
|
||||
t.Run(template[:min(len(template), 40)], func(t *testing.T) {
|
||||
if _, err := renderLarkPayload(template, larkTemplateValues{}); err == nil {
|
||||
t.Fatalf("template was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignLarkPayload(t *testing.T) {
|
||||
const timestamp = int64(1_599_360_473)
|
||||
if got, want := larkSignature(timestamp, "demo"), "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8="; got != want {
|
||||
t.Fatalf("signature = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
unsigned := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
||||
signed, err := signLarkPayload(unsigned, "demo", time.Unix(timestamp, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(signed, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["timestamp"] != "1599360473" || payload["sign"] != "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8=" {
|
||||
t.Fatalf("signed payload = %#v", payload)
|
||||
}
|
||||
|
||||
untouched, err := signLarkPayload(unsigned, "", time.Unix(timestamp, 0))
|
||||
if err != nil || string(untouched) != string(unsigned) {
|
||||
t.Fatalf("unsigned payload = %s, err = %v", untouched, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLarkResponse(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"code":0,"msg":"success"}`,
|
||||
`{"StatusCode":0,"StatusMessage":"success"}`,
|
||||
} {
|
||||
if err := validateLarkResponse(http.StatusOK, []byte(body)); err != nil {
|
||||
t.Fatalf("successful response %s = %v", body, err)
|
||||
}
|
||||
}
|
||||
for _, response := range []struct {
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{http.StatusBadGateway, `{"code":0}`},
|
||||
{http.StatusOK, `{"code":19021,"msg":"sign match fail or timestamp is not within one hour from current time","StatusCode":0}`},
|
||||
{http.StatusOK, `{"StatusCode":19021,"StatusMessage":"sign error"}`},
|
||||
{http.StatusOK, `{}`},
|
||||
{http.StatusOK, `not-json`},
|
||||
} {
|
||||
if err := validateLarkResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("validateLarkResponse(%d, %s) = %v", response.status, response.body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostLarkNotificationSendsJSONPayload(t *testing.T) {
|
||||
payload := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "vocat-lark-notification/1" {
|
||||
t.Errorf("User-Agent = %q", got)
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read body: %v", err)
|
||||
}
|
||||
if string(body) != string(payload) {
|
||||
t.Errorf("body = %s, want %s", body, payload)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
|
||||
}))
|
||||
t.Cleanup(provider.Close)
|
||||
|
||||
if err := postLarkNotification(context.Background(), provider.Client(), provider.URL, payload); err != nil {
|
||||
t.Fatalf("postLarkNotification() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLarkWebhookURL(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/lark-token",
|
||||
"https://open.larksuite.com:443/open-apis/bot/v2/hook/lark-token",
|
||||
} {
|
||||
if _, err := parseLarkWebhookURL(raw); err != nil {
|
||||
t.Errorf("parseLarkWebhookURL(%q) = %v", raw, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
"http://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||
"https://example.com/open-apis/bot/v2/hook/token",
|
||||
"https://open.larksuite.com/open-apis/bot/hook/token",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/token/extra",
|
||||
"https://open.larksuite.com/open-apis/bot/v2/hook/token?query=1",
|
||||
} {
|
||||
if _, err := parseLarkWebhookURL(raw); err == nil {
|
||||
t.Errorf("parseLarkWebhookURL(%q) accepted an invalid group bot webhook", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLarkNotificationConfig(t *testing.T) {
|
||||
valid := map[string]any{
|
||||
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||
"signing_enabled": true,
|
||||
"secret": "demo",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
}
|
||||
if err := validateLarkNotificationConfig(valid); err != nil {
|
||||
t.Fatalf("valid config = %v", err)
|
||||
}
|
||||
unsigned := map[string]any{
|
||||
"url": valid["url"],
|
||||
"signing_enabled": false,
|
||||
"payload_template": valid["payload_template"],
|
||||
}
|
||||
if err := validateLarkNotificationConfig(unsigned); err != nil {
|
||||
t.Fatalf("unsigned config = %v", err)
|
||||
}
|
||||
if secret := larkSigningSecret(map[string]any{"signing_enabled": false, "secret": "demo"}); secret != "" {
|
||||
t.Fatalf("disabled signing secret = %q", secret)
|
||||
}
|
||||
if secret := larkSigningSecret(valid); secret != "demo" {
|
||||
t.Fatalf("enabled signing secret = %q", secret)
|
||||
}
|
||||
for _, config := range []map[string]any{
|
||||
{"payload_template": valid["payload_template"]},
|
||||
{"url": valid["url"]},
|
||||
{"url": valid["url"], "signing_enabled": true, "payload_template": valid["payload_template"]},
|
||||
} {
|
||||
if err := validateLarkNotificationConfig(config); err == nil {
|
||||
t.Fatalf("invalid config was accepted: %#v", config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLarkRequestErrorRemovesWebhookURL(t *testing.T) {
|
||||
const webhookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/sensitive-token"
|
||||
err := sanitizeLarkRequestError(&url.Error{Op: "Post", URL: webhookURL, Err: errors.New("dial failed")})
|
||||
if strings.Contains(err.Error(), "sensitive-token") || err.Error() != "dial failed" {
|
||||
t.Fatalf("sanitized error = %q", err)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/i18n"
|
||||
localproxy "vocat/internal/proxy"
|
||||
"vocat/internal/store"
|
||||
@@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
payload.ID = id
|
||||
s.saveAndProbeUpstream(w, r, payload)
|
||||
case http.MethodPatch:
|
||||
var request struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
value, err := s.store.UpstreamProxy(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
value.Enabled = request.Enabled
|
||||
value.UpdatedAt = time.Now().UTC()
|
||||
if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
bindings, err := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
reconnectRequested := false
|
||||
var reconnectErrors []string
|
||||
for _, binding := range bindings {
|
||||
if binding.UpstreamProxyID != id {
|
||||
continue
|
||||
}
|
||||
requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
|
||||
reconnectRequested = reconnectRequested || requested
|
||||
if reconnectErr != nil {
|
||||
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
|
||||
}
|
||||
}
|
||||
response := upstreamProxyResponse(value.Redacted())
|
||||
response["reconnect_requested"] = reconnectRequested
|
||||
if len(reconnectErrors) > 0 {
|
||||
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": response})
|
||||
case http.MethodDelete:
|
||||
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
|
||||
if listErr != nil {
|
||||
@@ -117,7 +161,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
w.Header().Set("Allow", "PUT, PATCH, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -351,8 +395,8 @@ func (s *Server) saveAndProbeUpstream(
|
||||
)
|
||||
probeResponse := probeMap(probe, probeErr)
|
||||
message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
|
||||
if probeErr == nil && probe.UDPAssociateOK {
|
||||
message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。")
|
||||
if probeErr == nil && probe.UDPExchangeOK {
|
||||
message = i18n.T("代理已保存,SOCKS5 认证与真实 UDP 往返均通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -381,8 +425,8 @@ func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id
|
||||
8*time.Second,
|
||||
)
|
||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||
if probeErr == nil && result.UDPAssociateOK {
|
||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||
if probeErr == nil && result.UDPExchangeOK {
|
||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -435,8 +479,8 @@ func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Reques
|
||||
8*time.Second,
|
||||
)
|
||||
message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
|
||||
if probeErr == nil && result.UDPAssociateOK {
|
||||
message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
|
||||
if probeErr == nil && result.UDPExchangeOK {
|
||||
message = i18n.T("SOCKS5 认证与真实 UDP 往返探测通过。")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var proxyCountries = []proxyCountry{
|
||||
var namedProxyCountries = []proxyCountry{
|
||||
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
|
||||
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
|
||||
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
|
||||
@@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{
|
||||
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
|
||||
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
|
||||
}
|
||||
|
||||
var proxyCountries = buildProxyCountries()
|
||||
|
||||
func buildProxyCountries() []proxyCountry {
|
||||
byCode := make(map[string]proxyCountry)
|
||||
for _, country := range namedProxyCountries {
|
||||
byCode[country.Code] = country
|
||||
}
|
||||
for code, mccs := range device.MCCsByCountry() {
|
||||
country, found := byCode[code]
|
||||
if !found {
|
||||
country = proxyCountry{Code: code, Name: code}
|
||||
}
|
||||
country.MCCs = append([]string(nil), mccs...)
|
||||
byCode[code] = country
|
||||
}
|
||||
result := make([]proxyCountry, 0, len(byCode))
|
||||
for _, country := range byCode {
|
||||
result = append(result, country)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code })
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ type Server struct {
|
||||
updateApplying bool
|
||||
https *httpsmode.Manager
|
||||
netTraffic *liveNetTracker
|
||||
hostStats *hostStatsSampler
|
||||
publicIPMu sync.RWMutex
|
||||
publicIPs map[string]cachedPublicIP
|
||||
automaticTasks *automaticTaskScheduler
|
||||
@@ -133,6 +134,7 @@ func New(options Options) (*Server, error) {
|
||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||
https: options.HTTPS,
|
||||
netTraffic: newLiveNetTracker(),
|
||||
hostStats: newHostStatsSampler(),
|
||||
publicIPs: make(map[string]cachedPublicIP),
|
||||
updateCheck: update.CheckLatest,
|
||||
updateApply: update.ApplyLatest,
|
||||
@@ -142,6 +144,9 @@ func New(options Options) (*Server, error) {
|
||||
server.loadUILanguage(context.Background())
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", server.handleLiveness)
|
||||
mux.HandleFunc("/readyz", server.handleReadiness)
|
||||
mux.HandleFunc("/metrics", server.handleMetrics)
|
||||
mux.HandleFunc("/api/health", server.handleHealth)
|
||||
mux.HandleFunc("/api/auth/login", server.handleLogin)
|
||||
mux.HandleFunc("/api/auth/session", server.handleSession)
|
||||
|
||||
@@ -41,6 +41,7 @@ var notificationChannels = []string{
|
||||
"bark",
|
||||
"pushplus",
|
||||
"wecom",
|
||||
"lark",
|
||||
}
|
||||
|
||||
var notificationFields = map[string]map[string]string{
|
||||
@@ -65,6 +66,9 @@ var notificationFields = map[string]map[string]string{
|
||||
"wecom": {
|
||||
"urls": "strings", "payload_template": "string",
|
||||
},
|
||||
"lark": {
|
||||
"url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string",
|
||||
},
|
||||
}
|
||||
|
||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||
@@ -240,6 +244,20 @@ func decodeNotificationConfig(
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
|
||||
}
|
||||
if enabled && channel == "lark" {
|
||||
var resolved map[string]any
|
||||
if err := json.Unmarshal(config, &resolved); err != nil {
|
||||
return false, nil, fmt.Errorf("decode lark notification config: %w", err)
|
||||
}
|
||||
signingEnabled, _ := resolved["signing_enabled"].(bool)
|
||||
if signingEnabled && configString(resolved, "url") != store.SecretMask &&
|
||||
configString(resolved, "secret") == store.SecretMask {
|
||||
return false, nil, errors.New("lark.secret must be re-entered when lark.url changes")
|
||||
}
|
||||
if err := validateLarkNotificationConfig(resolved); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
return enabled, config, nil
|
||||
}
|
||||
|
||||
@@ -265,6 +283,9 @@ func validateNotificationField(
|
||||
if name == "text_template" {
|
||||
limit = 32768
|
||||
}
|
||||
if channel == "lark" && name == "payload_template" {
|
||||
limit = maxLarkPayloadBytes
|
||||
}
|
||||
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
||||
}
|
||||
@@ -300,6 +321,16 @@ func validateNotificationField(
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
if channel == "lark" && name == "payload_template" && value != "" {
|
||||
if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil {
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
if channel == "lark" && name == "url" && value != "" && value != store.SecretMask {
|
||||
if _, err := parseLarkWebhookURL(value); err != nil {
|
||||
return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err)
|
||||
}
|
||||
}
|
||||
case "integer":
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
@@ -387,7 +418,7 @@ func (s *Server) handleNotificationTest(
|
||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||
return
|
||||
}
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusNotImplemented,
|
||||
@@ -441,6 +472,8 @@ func (s *Server) handleNotificationTest(
|
||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||
case "lark":
|
||||
err = sendLarkNotificationTest(notificationContext, resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -546,8 +579,8 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
|
||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||
// settings form while allowing newly entered sensitive values in the same
|
||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
||||
// used by the other notification channels.
|
||||
// request. Provider webhook URLs can be sensitive lists, unlike the
|
||||
// string-based secrets used by the other notification channels.
|
||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
@@ -595,6 +628,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
||||
}
|
||||
case "wecom":
|
||||
return validateWecomNotificationConfig(config)
|
||||
case "lark":
|
||||
return validateLarkNotificationConfig(config)
|
||||
case "telegram":
|
||||
token := configString(config, "bot_token")
|
||||
if token == "" || token == store.SecretMask {
|
||||
|
||||
@@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m
|
||||
return response
|
||||
}
|
||||
|
||||
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
|
||||
func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
|
||||
if recorder.Code != http.StatusOK {
|
||||
@@ -179,6 +179,73 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||
secret := "lark-signing-secret"
|
||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||
first, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) {
|
||||
t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||
if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask {
|
||||
t.Fatalf("redacted Lark config = %#v", lark)
|
||||
}
|
||||
|
||||
second, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": store.SecretMask, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "lark")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) {
|
||||
t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
template := `{"msg_type":"text","content":{"text":{{message}}}}`
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"lark": map[string]any{
|
||||
"enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
|
||||
"signing_enabled": false, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
lark := response["data"].(map[string]any)["lark"].(map[string]any)
|
||||
if _, exists := lark["secret"]; exists {
|
||||
t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
@@ -232,6 +299,45 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored"
|
||||
storedConfig, err := json.Marshal(map[string]any{
|
||||
"url": storedURL,
|
||||
"signing_enabled": true,
|
||||
"secret": "stored-signing-secret",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
||||
Channel: "lark",
|
||||
Config: storedConfig,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"url": store.SecretMask,
|
||||
"signing_enabled": true,
|
||||
"secret": store.SecretMask,
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved["url"] != storedURL {
|
||||
t.Fatalf("resolved URL = %#v", resolved["url"])
|
||||
}
|
||||
if resolved["secret"] != "stored-signing-secret" {
|
||||
t.Fatalf("resolved secret = %#v", resolved["secret"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
cases := []struct {
|
||||
@@ -284,6 +390,36 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "invalid Lark payload template",
|
||||
body: `{"lark":{"enabled":true,"payload_template":"[]"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "enabled Lark config without webhook URL",
|
||||
body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "enabled Lark signing without secret",
|
||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "changed Lark URL with masked signing secret",
|
||||
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "insecure Lark group bot URL",
|
||||
body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "non-Lark group bot URL",
|
||||
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
|
||||
code: "invalid_notification_config",
|
||||
},
|
||||
{
|
||||
name: "null body",
|
||||
body: `null`,
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
const smsNotificationPollInterval = 2 * time.Second
|
||||
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"}
|
||||
|
||||
type smsNotification struct {
|
||||
DeviceID string
|
||||
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
||||
|
||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "bark", "email", "webhook", "wecom":
|
||||
case "bark", "email", "webhook", "wecom", "lark":
|
||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,6 +204,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
||||
return sendWebhookSMSNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkSMSValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,31 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) {
|
||||
message := smsNotification{
|
||||
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||
Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello",
|
||||
}
|
||||
smsValues := larkSMSValues(message)
|
||||
if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" ||
|
||||
smsValues["message"] != message.Text() || smsValues["content"] != "hello" ||
|
||||
smsValues["device_label"] != "EC20" {
|
||||
t.Fatalf("Lark SMS values = %#v", smsValues)
|
||||
}
|
||||
|
||||
taskValues := larkAutomaticTaskValues(automaticTaskNotification{
|
||||
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||
})
|
||||
if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" {
|
||||
t.Fatalf("Lark automatic task values = %#v", taskValues)
|
||||
}
|
||||
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||
if taskValues[name] != "" {
|
||||
t.Fatalf("%s = %q, want empty", name, taskValues[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
valid := map[string]map[string]any{
|
||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||
@@ -76,6 +101,12 @@ func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
},
|
||||
"lark": {
|
||||
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret",
|
||||
"signing_enabled": true,
|
||||
"secret": "signing-secret",
|
||||
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
|
||||
},
|
||||
}
|
||||
for channel, config := range valid {
|
||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||
|
||||
@@ -917,6 +917,56 @@ func TestNotificationArraySecretPreservation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "lark", Enabled: true,
|
||||
Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err := database.NotificationSetting(ctx, "lark")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redacted["url"] != SecretMask || redacted["secret"] != SecretMask {
|
||||
t.Fatalf("redacted Lark config = %#v", redacted)
|
||||
}
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "lark", Enabled: true,
|
||||
Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err = database.NotificationSetting(ctx, "lark")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) {
|
||||
t.Fatalf("stored Lark config = %s", setting.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) {
|
||||
setting := NotificationSetting{
|
||||
Config: json.RawMessage(`{"url":"","secret":""}`),
|
||||
SensitiveFields: []string{"url", "secret"},
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redacted["url"] != "" || redacted["secret"] != "" {
|
||||
t.Fatalf("empty sensitive values were masked: %#v", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
|
||||
@@ -610,6 +610,9 @@ func mergeJSONSecrets(
|
||||
func redactJSONValue(value any, replacement string) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed == "" {
|
||||
return ""
|
||||
}
|
||||
return replacement
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
|
||||
+46
-17
@@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
value, err := normalizeDeviceProxyBinding(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
createdAt := value.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
}
|
||||
updatedAt := value.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = now
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
|
||||
profile_name = excluded.profile_name,
|
||||
upstream_proxy_id = excluded.upstream_proxy_id,
|
||||
updated_at = excluded.updated_at
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertDeviceProxyBindingIfAbsent materializes a default route without ever
|
||||
// replacing an explicit (or concurrently-created) ICCID binding.
|
||||
func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) {
|
||||
value, err := normalizeDeviceProxyBinding(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO device_proxy_bindings (
|
||||
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO NOTHING
|
||||
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err)
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.ProfileName = strings.TrimSpace(value.ProfileName)
|
||||
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
|
||||
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
|
||||
return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
if value.UpdatedAt.IsZero() {
|
||||
value.UpdatedAt = now
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
|
||||
return deviceProxyBinding(s.db.QueryRowContext(
|
||||
ctx,
|
||||
|
||||
@@ -24,6 +24,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
||||
return []string{"token"}
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
case "lark":
|
||||
return []string{"url", "secret"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -94,9 +95,15 @@ func mergeConcatSegment(
|
||||
}
|
||||
}
|
||||
}
|
||||
prior, alreadyHad := parts[sequence]
|
||||
changed = !alreadyHad || prior != segmentBody
|
||||
// Some IMS stacks hand us a cumulative segment: sequence 2 contains the
|
||||
// already-decoded text of sequence 1 followed by its own payload. Keep a
|
||||
// snapshot so normalizing that representation remains idempotent on a later
|
||||
// redelivery of the same segment.
|
||||
previousParts := maps.Clone(parts)
|
||||
normalizeCumulativeConcatParts(previousParts)
|
||||
parts[sequence] = segmentBody
|
||||
normalizeCumulativeConcatParts(parts)
|
||||
changed = !maps.Equal(previousParts, parts)
|
||||
|
||||
sequences := make([]int, 0, len(parts))
|
||||
for n := range parts {
|
||||
@@ -130,3 +137,24 @@ func mergeConcatSegment(
|
||||
}
|
||||
return joined.String(), json.RawMessage(encoded), changed, nil
|
||||
}
|
||||
|
||||
// normalizeCumulativeConcatParts converts cumulative IMS segment bodies back
|
||||
// into ordinary per-segment bodies. It only removes an exact, non-empty prefix
|
||||
// assembled from every preceding sequence starting at 1, and only when the
|
||||
// current value also contains additional text. That deliberately leaves equal
|
||||
// repeated segments and incomplete/out-of-order prefixes untouched.
|
||||
func normalizeCumulativeConcatParts(parts map[int]string) {
|
||||
var prefix strings.Builder
|
||||
for sequence := 1; ; sequence++ {
|
||||
text, ok := parts[sequence]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assembled := prefix.String()
|
||||
if assembled != "" && len(text) > len(assembled) && strings.HasPrefix(text, assembled) {
|
||||
text = strings.TrimPrefix(text, assembled)
|
||||
parts[sequence] = text
|
||||
}
|
||||
prefix.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,62 @@ func TestMergeConcatSegmentRedeliveryIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentNormalizesCumulativeIMSPart(t *testing.T) {
|
||||
first := strings.Repeat("安全提醒", 17)
|
||||
want := first + "请通过官方渠道核实。"
|
||||
_, extra, _, err := mergeConcatSegment(nil, first, concatExtra(t, 8, 2, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, normalized, changed, err := mergeConcatSegment(extra, want, concatExtra(t, 8, 2, 2))
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("cumulative segment: body=%q changed=%v err=%v", body, changed, err)
|
||||
}
|
||||
if body != want {
|
||||
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||
}
|
||||
|
||||
// Redelivering the cumulative wire representation must compare equal to the
|
||||
// normalized stored representation and must not churn the durable row id.
|
||||
body, _, changed, err = mergeConcatSegment(normalized, want, concatExtra(t, 8, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed || body != want {
|
||||
t.Fatalf("redelivery: body=%q changed=%v, want %q/false", body, changed, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentNormalizesCumulativeIMSPartOutOfOrder(t *testing.T) {
|
||||
first := strings.Repeat("甲", 67)
|
||||
want := first + "尾段"
|
||||
_, extra, _, err := mergeConcatSegment(nil, want, concatExtra(t, 12, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, changed, err := mergeConcatSegment(extra, first, concatExtra(t, 12, 2, 1))
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("out-of-order segment: body=%q changed=%v err=%v", body, changed, err)
|
||||
}
|
||||
if body != want {
|
||||
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentKeepsEqualRepeatedPart(t *testing.T) {
|
||||
_, extra, _, err := mergeConcatSegment(nil, "重复", concatExtra(t, 13, 2, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _, err := mergeConcatSegment(extra, "重复", concatExtra(t, 13, 2, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != "重复重复" {
|
||||
t.Fatalf("body = %q, want intentional equal segments preserved", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
||||
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLongSMSReassemblySurvivesServiceRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
const (
|
||||
deviceID = "dajiang"
|
||||
imei = "867394042309830"
|
||||
peer = "+447700900123"
|
||||
)
|
||||
messageID := StableConcatMessageID("ims", imei, deviceID, peer, 27, 2)
|
||||
|
||||
database, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustSaveDevice(t, database, deviceID, "大疆")
|
||||
first, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第一段:安全提醒,",
|
||||
Timestamp: time.Unix(1_700_000_000, 0).UTC(), Status: "received", Source: "ims",
|
||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ConcatSMSReadyToNotify(first.MessageID, first.Extra) {
|
||||
t.Fatal("partial message must not be ready before restart")
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err = Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
second, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||
Timestamp: time.Unix(1_700_000_030, 0).UTC(), Status: "received", Source: "ims",
|
||||
PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Body != "第一段:安全提醒,第二段:请通过官方渠道核实。" ||
|
||||
!ConcatSMSReadyToNotify(second.MessageID, second.Extra) {
|
||||
t.Fatalf("reassembled message after restart = %#v", second)
|
||||
}
|
||||
messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: deviceID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].ID != second.ID {
|
||||
t.Fatalf("stored messages after restart = %#v, want one merged row", messages)
|
||||
}
|
||||
redelivered, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: messageID, DeviceID: deviceID, ModemIMEI: imei, IMSI: "23433",
|
||||
Peer: peer, Direction: "inbound", Body: "第二段:请通过官方渠道核实。",
|
||||
Status: "received", Source: "ims", PartsTotal: 2, Extra: concatExtra(t, 27, 2, 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redelivered.ID != second.ID || redelivered.Body != second.Body {
|
||||
t.Fatalf("redelivery duplicated or changed message: %#v", redelivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartDeliveryReportsSurviveServiceRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
database, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustSaveDevice(t, database, "dajiang", "大疆")
|
||||
extra := json.RawMessage(`{"transport":"ims","part_results":[{"reference":51},{"reference":52}]}`)
|
||||
sent, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: "ims-submit-restart", DeviceID: "dajiang", IMSI: "23433",
|
||||
Peer: "+447700900123", Direction: "outbound", Body: "multipart",
|
||||
Status: "accepted_by_ims", Source: "ims", PartsTotal: 2,
|
||||
DeliveryState: "accepted_by_ims", Read: true, Extra: extra,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err = Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||
MessageReference: 51, StatusCode: 0, DeliveryState: "delivered",
|
||||
})
|
||||
if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" {
|
||||
t.Fatalf("first report after restart = (%#v, %v)", first, err)
|
||||
}
|
||||
second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{
|
||||
DeviceID: "dajiang", IMSI: "23433", Peer: "+447700900123", Source: "ims",
|
||||
MessageReference: 52, StatusCode: 0, DeliveryState: "delivered",
|
||||
})
|
||||
if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" {
|
||||
t.Fatalf("second report after restart = (%#v, %v)", second, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectSystemdUnitUsesExplicitOverride(t *testing.T) {
|
||||
t.Setenv("VOCAT_SYSTEMD_UNIT", "vocat-test.service")
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if got := detectSystemdUnit(logger); got != "vocat-test.service" {
|
||||
t.Fatalf("detectSystemdUnit() = %q, want vocat-test.service", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdUnitFromCgroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
}{
|
||||
{name: "cgroup v2", data: "0::/system.slice/vocat-test.service\n", want: "vocat-test.service"},
|
||||
{name: "legacy", data: "1:name=systemd:/system.slice/[email protected]\n", want: "[email protected]"},
|
||||
{name: "no service", data: "0::/user.slice/user-1000.slice/session-1.scope\n", want: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := systemdUnitFromCgroup(test.data); got != test.want {
|
||||
t.Fatalf("systemdUnitFromCgroup() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSystemdUnitRejectsArgumentsAndPaths(t *testing.T) {
|
||||
for _, value := range []string{"vocat", "../vocat.service", "vocat.service --now", "vocat.service/other"} {
|
||||
if validSystemdUnit.MatchString(value) {
|
||||
t.Fatalf("validSystemdUnit unexpectedly accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -320,17 +322,63 @@ func RestartService(logger *slog.Logger) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||
}
|
||||
unit := detectSystemdUnit(logger)
|
||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", unit)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||
return fmt.Errorf("systemctl restart vocat: %w", err)
|
||||
return fmt.Errorf("systemctl restart %s: %w", unit, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var validSystemdUnit = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`)
|
||||
|
||||
func detectSystemdUnit(logger *slog.Logger) string {
|
||||
if configured := strings.TrimSpace(os.Getenv("VOCAT_SYSTEMD_UNIT")); validSystemdUnit.MatchString(configured) {
|
||||
return configured
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||
if unit := systemdUnitFromCgroup(string(data)); unit != "" {
|
||||
return unit
|
||||
}
|
||||
}
|
||||
// Some cgroup namespaces hide the unit name. Query loaded services and
|
||||
// identify the unit whose MainPID is this process before falling back.
|
||||
list := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain")
|
||||
if output, err := list.Output(); err == nil {
|
||||
pid := strconv.Itoa(os.Getpid())
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 || !validSystemdUnit.MatchString(fields[0]) {
|
||||
continue
|
||||
}
|
||||
show := exec.Command("systemctl", "show", fields[0], "--property=MainPID", "--value")
|
||||
if value, showErr := show.Output(); showErr == nil && strings.TrimSpace(string(value)) == pid {
|
||||
return fields[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Warn("could not identify the current systemd unit; using vocat.service", "hint", "set VOCAT_SYSTEMD_UNIT for a custom unit")
|
||||
}
|
||||
return "vocat.service"
|
||||
}
|
||||
|
||||
func systemdUnitFromCgroup(data string) string {
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
for _, part := range strings.Split(line, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if validSystemdUnit.MatchString(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveDefaultTarget returns the conventional install path when present,
|
||||
// falling back to the running executable. This lets `vocat update` "just work"
|
||||
// on the standard systemd host without flags.
|
||||
|
||||
@@ -1,52 +1,569 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const att310280EPDG = "epdg.epc.att.net"
|
||||
const (
|
||||
CarrierProfileSchemaVersion = 1
|
||||
CarrierProfileStandard = "standard-3gpp"
|
||||
IKEProposalModern = "modern"
|
||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||
IMSProfileStandard = "standard"
|
||||
IMSProfileO2Germany = "o2-germany"
|
||||
IMSProfileATT = "att"
|
||||
)
|
||||
|
||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
||||
// VoWiFi access gateway.
|
||||
// CarrierProfile contains only interoperability choices that cannot be
|
||||
// reliably discovered from the SIM or negotiated with the network. All
|
||||
// protocol layers consume this common result so their carrier handling cannot
|
||||
// drift into separate MCC/MNC switch statements.
|
||||
type CarrierProfile struct {
|
||||
ID string
|
||||
MatchSource string
|
||||
RouteMCC string
|
||||
RouteMNC string
|
||||
EPDG string
|
||||
IKEProposal string
|
||||
AdvertiseEAPOnly bool
|
||||
IMSTransport string
|
||||
IMSIdentityProfile string
|
||||
IMSRegisterProfile string
|
||||
IMSIPSecEncryption string
|
||||
SMSCenter string
|
||||
PANICountry string
|
||||
PANINode string
|
||||
IMSDialURIScheme string
|
||||
IMSUserEqPhone bool
|
||||
IMSVoiceCodecs []string
|
||||
}
|
||||
|
||||
type carrierProfileDocument struct {
|
||||
Version int `json:"version"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
}
|
||||
|
||||
type carrierProfileRule struct {
|
||||
ID string `json:"id"`
|
||||
Match carrierProfileMatch `json:"match,omitzero"`
|
||||
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
|
||||
Route carrierProfileRoute `json:"route,omitzero"`
|
||||
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
|
||||
IKE carrierProfileIKE `json:"ike,omitzero"`
|
||||
IMS carrierProfileIMS `json:"ims,omitzero"`
|
||||
}
|
||||
|
||||
type carrierProfileMatch struct {
|
||||
HomePLMNs []string `json:"home_plmns,omitempty"`
|
||||
IMSIPrefixes []string `json:"imsi_prefixes,omitempty"`
|
||||
ICCIDPrefixes []string `json:"iccid_prefixes,omitempty"`
|
||||
SPNs []string `json:"spns,omitempty"`
|
||||
GID1Prefixes []string `json:"gid1_prefixes,omitempty"`
|
||||
GID2Prefixes []string `json:"gid2_prefixes,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileRoute struct {
|
||||
MCC string `json:"mcc,omitempty"`
|
||||
MNC string `json:"mnc,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileEPDG struct {
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
DNSHosts []string `json:"dns_hosts,omitempty"`
|
||||
DNSClientSubnet string `json:"dns_client_subnet,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileIKE struct {
|
||||
Proposal string `json:"proposal,omitempty"`
|
||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only,omitempty"`
|
||||
}
|
||||
|
||||
type carrierProfileIMS struct {
|
||||
Transport string `json:"transport,omitempty"`
|
||||
IdentityProfile string `json:"identity_profile,omitempty"`
|
||||
RegisterProfile string `json:"register_profile,omitempty"`
|
||||
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
|
||||
SMSCenter string `json:"sms_center,omitempty"`
|
||||
PANICountry string `json:"pani_country,omitempty"`
|
||||
PANINode string `json:"pani_node,omitempty"`
|
||||
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
|
||||
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
|
||||
VoiceCodecs []string `json:"voice_codecs,omitempty"`
|
||||
}
|
||||
|
||||
//go:embed carrier_profiles.json
|
||||
var carrierProfilesJSON []byte
|
||||
|
||||
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||
|
||||
var externalCarrierProfiles = struct {
|
||||
sync.RWMutex
|
||||
rules []carrierProfileRule
|
||||
}{}
|
||||
|
||||
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||
rules, err := loadCarrierProfiles(encoded)
|
||||
if err != nil {
|
||||
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func loadCarrierProfiles(encoded []byte) ([]carrierProfileRule, error) {
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if document.Version != CarrierProfileSchemaVersion {
|
||||
return nil, fmt.Errorf("unsupported carrier profile version %d", document.Version)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(document.Profiles))
|
||||
for index := range document.Profiles {
|
||||
rule := &document.Profiles[index]
|
||||
rule.ID = strings.TrimSpace(rule.ID)
|
||||
if rule.ID == "" {
|
||||
return nil, fmt.Errorf("carrier profile %d ID is empty", index)
|
||||
}
|
||||
if _, duplicate := seen[rule.ID]; duplicate {
|
||||
return nil, errors.New("duplicate carrier profile " + rule.ID)
|
||||
}
|
||||
seen[rule.ID] = struct{}{}
|
||||
if !validCarrierProfileRule(*rule) {
|
||||
return nil, errors.New("invalid carrier profile " + rule.ID)
|
||||
}
|
||||
}
|
||||
return document.Profiles, nil
|
||||
}
|
||||
|
||||
// LoadCarrierProfileDirectory replaces the installed profile set with all
|
||||
// valid JSON documents in dir. A missing directory is an empty set. Profiles
|
||||
// are sorted by filename; later profiles win only when selector specificity is
|
||||
// equal, so a broad installed PLMN rule cannot hide a constrained MVNO rule.
|
||||
func LoadCarrierProfileDirectory(dir string) error {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return errors.New("carrier profile directory is empty")
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
externalCarrierProfiles.Lock()
|
||||
externalCarrierProfiles.rules = nil
|
||||
externalCarrierProfiles.Unlock()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read carrier profile directory %q: %w", dir, err)
|
||||
}
|
||||
if len(entries) > 256 {
|
||||
return fmt.Errorf("carrier profile directory %q contains %d entries; maximum is 256", dir, len(entries))
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
loaded := make([]carrierProfileRule, 0, len(entries))
|
||||
seen := make(map[string]string)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat carrier profile %q: %w", path, err)
|
||||
}
|
||||
if info.Size() > 1<<20 {
|
||||
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open carrier profile %q: %w", path, err)
|
||||
}
|
||||
encoded, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read carrier profile %q: %w", path, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close carrier profile %q: %w", path, closeErr)
|
||||
}
|
||||
if len(encoded) > 1<<20 {
|
||||
return fmt.Errorf("carrier profile %q exceeds 1 MiB", path)
|
||||
}
|
||||
rules, err := loadCarrierProfiles(encoded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load carrier profile %q: %w", path, err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if previous := seen[rule.ID]; previous != "" {
|
||||
return fmt.Errorf("carrier profile %q is duplicated in %q and %q", rule.ID, previous, path)
|
||||
}
|
||||
seen[rule.ID] = path
|
||||
loaded = append(loaded, rule)
|
||||
}
|
||||
}
|
||||
externalCarrierProfiles.Lock()
|
||||
externalCarrierProfiles.rules = loaded
|
||||
externalCarrierProfiles.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func carrierProfilesSnapshot() []carrierProfileRule {
|
||||
externalCarrierProfiles.RLock()
|
||||
defer externalCarrierProfiles.RUnlock()
|
||||
result := make([]carrierProfileRule, 0, len(builtinCarrierProfiles)+len(externalCarrierProfiles.rules))
|
||||
result = append(result, builtinCarrierProfiles...)
|
||||
result = append(result, externalCarrierProfiles.rules...)
|
||||
return result
|
||||
}
|
||||
|
||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
matches = append(matches, rule.MatchAny...)
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, match := range matches {
|
||||
if emptyCarrierProfileMatch(match) {
|
||||
return false
|
||||
}
|
||||
for _, plmn := range match.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range match.IMSIPrefixes {
|
||||
if len(prefix) < 5 || len(prefix) > 18 || !decimalString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range match.ICCIDPrefixes {
|
||||
if len(prefix) < 5 || len(prefix) > 22 || !decimalString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, prefix := range append(append([]string(nil), match.GID1Prefixes...), match.GID2Prefixes...) {
|
||||
if len(prefix) < 1 || len(prefix) > 64 || !hexString(prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, spn := range match.SPNs {
|
||||
if strings.TrimSpace(spn) == "" || len(spn) > 128 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
||||
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
||||
return false
|
||||
}
|
||||
if proposal := strings.TrimSpace(rule.IKE.Proposal); proposal != "" &&
|
||||
proposal != IKEProposalModern && proposal != IKEProposalLegacy {
|
||||
return false
|
||||
}
|
||||
if transport := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); transport != "" &&
|
||||
transport != "tcp" && transport != "udp" {
|
||||
return false
|
||||
}
|
||||
if encryption := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); encryption != "" &&
|
||||
encryption != "aes-cbc" && encryption != "null" {
|
||||
return false
|
||||
}
|
||||
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
|
||||
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
|
||||
return false
|
||||
}
|
||||
if scheme := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); scheme != "" && scheme != "tel" && scheme != "sip" {
|
||||
return false
|
||||
}
|
||||
for _, codec := range rule.IMS.VoiceCodecs {
|
||||
switch strings.ToUpper(strings.TrimSpace(codec)) {
|
||||
case "PCMA", "PCMU", "AMR", "AMR-WB":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func emptyCarrierProfileMatch(match carrierProfileMatch) bool {
|
||||
return len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0
|
||||
}
|
||||
|
||||
func hexString(value string) bool {
|
||||
for _, item := range value {
|
||||
if item >= '0' && item <= '9' || item >= 'a' && item <= 'f' || item >= 'A' && item <= 'F' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func decimalString(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range value {
|
||||
if item < '0' || item > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||
// PLMN without weakening the default match for unrelated subscriptions.
|
||||
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
resolved := CarrierProfile{
|
||||
ID: CarrierProfileStandard,
|
||||
MatchSource: "standard",
|
||||
IKEProposal: IKEProposalModern,
|
||||
AdvertiseEAPOnly: true,
|
||||
IMSIdentityProfile: IMSProfileStandard,
|
||||
IMSRegisterProfile: IMSProfileStandard,
|
||||
IMSIPSecEncryption: "aes-cbc",
|
||||
IMSDialURIScheme: "tel",
|
||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||
}
|
||||
bestScore := -1
|
||||
for _, rule := range carrierProfilesSnapshot() {
|
||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||
if !matched || score < bestScore {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// matchCarrierProfileRule evaluates each selector set as an alternative. This
|
||||
// mirrors carrier-bundle and Android carrier-ID semantics: fields inside one
|
||||
// selector are ANDed, while separate selector records for the same brand are
|
||||
// ORed (for example, giffgaff can be identified by either GID1 or SPN).
|
||||
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
||||
bestScore := -1
|
||||
bestSource := ""
|
||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
matches = append(matches, rule.MatchAny...)
|
||||
for _, match := range matches {
|
||||
score, source, matched := matchCarrierProfile(match, identity)
|
||||
if matched && score > bestScore {
|
||||
bestScore = score
|
||||
bestSource = source
|
||||
}
|
||||
}
|
||||
return bestScore, bestSource, bestScore >= 0
|
||||
}
|
||||
|
||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
||||
score := 0
|
||||
sources := make([]string, 0, 6)
|
||||
if len(match.HomePLMNs) > 0 {
|
||||
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||
if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool {
|
||||
return canonicalPLMNValue(value) == wanted
|
||||
}) {
|
||||
return 0, "", false
|
||||
}
|
||||
score += 100
|
||||
sources = append(sources, "hplmn")
|
||||
}
|
||||
for _, selector := range []struct {
|
||||
name string
|
||||
weight int
|
||||
values []string
|
||||
actual string
|
||||
foldCase bool
|
||||
}{
|
||||
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
|
||||
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
|
||||
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
|
||||
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
|
||||
} {
|
||||
if len(selector.values) == 0 {
|
||||
continue
|
||||
}
|
||||
actual := strings.TrimSpace(selector.actual)
|
||||
if actual == "" || !matchesAny(selector.values, func(prefix string) bool {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if selector.foldCase {
|
||||
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
||||
}
|
||||
return strings.HasPrefix(actual, prefix)
|
||||
}) {
|
||||
return 0, "", false
|
||||
}
|
||||
score += selector.weight
|
||||
sources = append(sources, selector.name)
|
||||
}
|
||||
if len(match.SPNs) > 0 {
|
||||
spn := strings.TrimSpace(identity.SPN)
|
||||
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||
}) {
|
||||
return 0, "", false
|
||||
}
|
||||
score += 20
|
||||
sources = append(sources, "spn")
|
||||
}
|
||||
return score, strings.Join(sources, "+"), score > 0
|
||||
}
|
||||
|
||||
func matchesAny(values []string, match func(string) bool) bool {
|
||||
for _, value := range values {
|
||||
if match(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
|
||||
base.ID = rule.ID
|
||||
base.MatchSource = source
|
||||
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
||||
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
||||
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
||||
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
||||
base.IKEProposal = value
|
||||
}
|
||||
if rule.IKE.AdvertiseEAPOnly != nil {
|
||||
base.AdvertiseEAPOnly = *rule.IKE.AdvertiseEAPOnly
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); value != "" {
|
||||
base.IMSTransport = value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.IMS.IdentityProfile); value != "" {
|
||||
base.IMSIdentityProfile = value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.IMS.RegisterProfile); value != "" {
|
||||
base.IMSRegisterProfile = value
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); value != "" {
|
||||
base.IMSIPSecEncryption = value
|
||||
}
|
||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
|
||||
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
|
||||
base.IMSDialURIScheme = value
|
||||
}
|
||||
if rule.IMS.UserEqPhone != nil {
|
||||
base.IMSUserEqPhone = *rule.IMS.UserEqPhone
|
||||
}
|
||||
if len(rule.IMS.VoiceCodecs) > 0 {
|
||||
base.IMSVoiceCodecs = normalizeVoiceCodecs(rule.IMS.VoiceCodecs)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func normalizeVoiceCodecs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canonicalPLMN(mcc, mnc string) string {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) {
|
||||
return ""
|
||||
}
|
||||
for len(mnc) < 3 {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
return mcc + mnc
|
||||
}
|
||||
|
||||
func canonicalPLMNValue(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "/", ""))
|
||||
if len(value) != 5 && len(value) != 6 {
|
||||
return ""
|
||||
}
|
||||
return canonicalPLMN(value[:3], value[3:])
|
||||
}
|
||||
|
||||
// AssignedRoutePLMN remains available to callers that only have the legacy
|
||||
// identifier pair. New code resolves the complete SIMIdentity so SPN/GID
|
||||
// selectors can participate.
|
||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
switch {
|
||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
||||
return "234", "15", true
|
||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
||||
// the initial VoWiFi provisioning path.
|
||||
return "234", "30", true
|
||||
default:
|
||||
return "", "", false
|
||||
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
|
||||
if len(identity.IMSI) >= 5 {
|
||||
identity.HomeMCC = identity.IMSI[:3]
|
||||
for _, length := range []int{3, 2} {
|
||||
if len(identity.IMSI) < 3+length {
|
||||
continue
|
||||
}
|
||||
identity.HomeMNC = identity.IMSI[3 : 3+length]
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.RouteMCC != "" {
|
||||
return profile.RouteMCC, profile.RouteMNC, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
||||
// drift between protocol layers.
|
||||
func IsATT310280(identity SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
||||
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
|
||||
}
|
||||
|
||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
if strings.TrimSpace(identity.EPDG) != "" {
|
||||
return identity
|
||||
}
|
||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
switch {
|
||||
case profile.EPDG != "":
|
||||
identity.EPDG = profile.EPDG
|
||||
case profile.RouteMCC != "":
|
||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||
func EPDGDNSClientSubnet(host string) string {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
for _, rule := range carrierProfilesSnapshot() {
|
||||
for _, candidate := range rule.EPDG.DNSHosts {
|
||||
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
||||
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func standardEPDGHostname(mcc, mnc string) string {
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
for len(mnc) < 3 {
|
||||
|
||||
@@ -54,3 +54,94 @@ func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
||||
})
|
||||
if profile.ID != CarrierProfileStandard || profile.MatchSource != "standard" {
|
||||
t.Fatalf("default profile = %#v", profile)
|
||||
}
|
||||
if profile.IKEProposal != IKEProposalModern || !profile.AdvertiseEAPOnly ||
|
||||
profile.IMSIdentityProfile != IMSProfileStandard || profile.IMSRegisterProfile != IMSProfileStandard {
|
||||
t.Fatalf("default profile lost standard capabilities: %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
||||
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
||||
})
|
||||
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
||||
t.Fatalf("MVNO profile = %#v", profile)
|
||||
}
|
||||
if profile.MatchSource != "hplmn+imsi+iccid" {
|
||||
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity SIMIdentity
|
||||
source string
|
||||
}{
|
||||
{
|
||||
name: "Apple GID1 selector",
|
||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
|
||||
source: "hplmn+gid1",
|
||||
},
|
||||
{
|
||||
name: "Android SPN selector",
|
||||
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"},
|
||||
source: "hplmn+spn",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(test.identity)
|
||||
if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source {
|
||||
t.Fatalf("giffgaff profile = %#v", profile)
|
||||
}
|
||||
if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone {
|
||||
t.Fatalf("giffgaff IMS settings = %#v", profile)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
generic := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10",
|
||||
})
|
||||
if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" {
|
||||
t.Fatalf("generic O2 profile = %#v", generic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
})
|
||||
if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" {
|
||||
t.Fatalf("EE-hosted profile = %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
||||
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
||||
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
||||
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
||||
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
||||
}
|
||||
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
const (
|
||||
maxIPCCBytes = 32 << 20
|
||||
maxIPCCFiles = 512
|
||||
maxIPCCPlistBytes = 4 << 20
|
||||
maxIPCCPlistTotalBytes = 64 << 20
|
||||
installedProfileFileMode = 0o600
|
||||
)
|
||||
|
||||
var supportedSIMPLMN = regexp.MustCompile(`^[0-9]{5,6}$`)
|
||||
|
||||
// IPCCImportOptions controls deterministic bundle selection and profile ID
|
||||
// generation. Bundle may be a full archive directory or the final .bundle
|
||||
// name. ProfileID overrides the generated, filesystem-safe ID.
|
||||
type IPCCImportOptions struct {
|
||||
Bundle string
|
||||
ProfileID string
|
||||
}
|
||||
|
||||
// IPCCImportWarning describes a value that was ambiguous, unsafe, or outside
|
||||
// VoCat's portable carrier-profile schema. Such values are reported but never
|
||||
// copied into the installed profile.
|
||||
type IPCCImportWarning struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// IPCCImportResult contains a reviewable carrier-profile document. Document
|
||||
// is complete JSON and can be installed without retaining the Apple archive.
|
||||
type IPCCImportResult struct {
|
||||
SourceFile string `json:"source_file"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
Bundle string `json:"bundle"`
|
||||
CarrierName string `json:"carrier_name"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
Warnings []IPCCImportWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type ipccPlist struct {
|
||||
name string
|
||||
root map[string]any
|
||||
}
|
||||
|
||||
type ipccWarningSet struct {
|
||||
items []IPCCImportWarning
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func (set *ipccWarningSet) add(code, message, plistPath string) {
|
||||
if set.seen == nil {
|
||||
set.seen = make(map[string]struct{})
|
||||
}
|
||||
item := IPCCImportWarning{Code: code, Message: message, Path: plistPath}
|
||||
// Device-family override plists often repeat the same setting. Preserve the
|
||||
// first concrete path while keeping the review output compact.
|
||||
key := code + "\x00" + message
|
||||
if _, duplicate := set.seen[key]; duplicate {
|
||||
return
|
||||
}
|
||||
set.seen[key] = struct{}{}
|
||||
set.items = append(set.items, item)
|
||||
}
|
||||
|
||||
// ImportCarrierIPCC converts a local Apple .ipcc/.zip archive into one
|
||||
// reviewable VoCat carrier profile. It never contacts Apple and never installs the
|
||||
// result. Device-specific and security-weakening values are deliberately
|
||||
// omitted with structured warnings.
|
||||
func ImportCarrierIPCC(filePath string, options IPCCImportOptions) (IPCCImportResult, error) {
|
||||
filePath = strings.TrimSpace(filePath)
|
||||
if filePath == "" {
|
||||
return IPCCImportResult{}, errors.New("IPCC path is empty")
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("stat IPCC %q: %w", filePath, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return IPCCImportResult{}, fmt.Errorf("IPCC %q is not a regular file", filePath)
|
||||
}
|
||||
if info.Size() <= 0 || info.Size() > maxIPCCBytes {
|
||||
return IPCCImportResult{}, fmt.Errorf("IPCC %q size %d is outside 1..%d bytes", filePath, info.Size(), maxIPCCBytes)
|
||||
}
|
||||
encoded, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("read IPCC %q: %w", filePath, err)
|
||||
}
|
||||
archive, err := zip.NewReader(bytes.NewReader(encoded), int64(len(encoded)))
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("open IPCC %q: %w", filePath, err)
|
||||
}
|
||||
if len(archive.File) > maxIPCCFiles {
|
||||
return IPCCImportResult{}, fmt.Errorf("IPCC contains %d files; maximum is %d", len(archive.File), maxIPCCFiles)
|
||||
}
|
||||
|
||||
bundleRoots := carrierBundleRoots(archive.File)
|
||||
bundleRoot, err := selectCarrierBundle(bundleRoots, options.Bundle)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, err
|
||||
}
|
||||
plists, err := readCarrierBundlePlists(archive.File, bundleRoot)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, err
|
||||
}
|
||||
primary := plists[0]
|
||||
warnings := &ipccWarningSet{}
|
||||
carrierName := firstNonempty(
|
||||
plistString(primary.root["CarrierName"]),
|
||||
statusBarCarrierName(primary.root),
|
||||
strings.TrimSuffix(path.Base(bundleRoot), path.Ext(bundleRoot)),
|
||||
)
|
||||
|
||||
matches, plmns, err := importCarrierSelectors(primary.root, plists, warnings)
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("import selectors from %s: %w", primary.name, err)
|
||||
}
|
||||
profileID := strings.TrimSpace(options.ProfileID)
|
||||
if profileID == "" {
|
||||
profileID = generatedIPCCProfileID(carrierName, plmns)
|
||||
}
|
||||
if !validInstalledProfileID(profileID) {
|
||||
return IPCCImportResult{}, fmt.Errorf("profile ID %q must match [a-z0-9][a-z0-9._-]{0,63}", profileID)
|
||||
}
|
||||
|
||||
rule := carrierProfileRule{ID: profileID}
|
||||
if len(matches) == 1 {
|
||||
rule.Match = matches[0]
|
||||
} else {
|
||||
rule.MatchAny = matches
|
||||
}
|
||||
importCarrierEPDG(&rule, plists, warnings)
|
||||
importCarrierIKE(&rule, plists, warnings)
|
||||
importCarrierIMS(&rule, plists, warnings)
|
||||
inspectIgnoredCarrierFields(plists, warnings)
|
||||
if !validCarrierProfileRule(rule) {
|
||||
return IPCCImportResult{}, errors.New("converted IPCC profile is not valid")
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(encoded)
|
||||
document := struct {
|
||||
Version int `json:"version"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
}{
|
||||
Version: CarrierProfileSchemaVersion,
|
||||
Metadata: map[string]string{
|
||||
"source": "user-supplied Apple carrier bundle",
|
||||
"source_sha256": hex.EncodeToString(sum[:]),
|
||||
"bundle": bundleRoot,
|
||||
"generated_by": "vocat carrier import-ipcc",
|
||||
},
|
||||
Profiles: []carrierProfileRule{rule},
|
||||
}
|
||||
documentJSON, err := json.MarshalIndent(document, "", " ")
|
||||
if err != nil {
|
||||
return IPCCImportResult{}, fmt.Errorf("encode imported carrier profile: %w", err)
|
||||
}
|
||||
return IPCCImportResult{
|
||||
SourceFile: filepath.Base(filePath),
|
||||
SourceSHA256: hex.EncodeToString(sum[:]),
|
||||
Bundle: bundleRoot,
|
||||
CarrierName: carrierName,
|
||||
ProfileID: profileID,
|
||||
Document: append(documentJSON, '\n'),
|
||||
Warnings: warnings.items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InstallCarrierIPCCResult atomically writes an already-reviewed import result
|
||||
// to dir. Existing files are never replaced; importing an update therefore
|
||||
// requires an explicit operator decision outside this function.
|
||||
func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, error) {
|
||||
if !validInstalledProfileID(result.ProfileID) {
|
||||
return "", fmt.Errorf("invalid profile ID %q", result.ProfileID)
|
||||
}
|
||||
if len(result.Document) == 0 {
|
||||
return "", errors.New("import result has no profile document")
|
||||
}
|
||||
if _, err := loadCarrierProfiles(result.Document); err != nil {
|
||||
return "", fmt.Errorf("validate imported profile: %w", err)
|
||||
}
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return "", errors.New("carrier profile directory is empty")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create carrier profile directory %q: %w", dir, err)
|
||||
}
|
||||
target := filepath.Join(dir, result.ProfileID+".json")
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", fmt.Errorf("carrier profile %q already exists", target)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("stat carrier profile %q: %w", target, err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(dir, "."+result.ProfileID+"-*.tmp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temporary carrier profile: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
removeTemporary := true
|
||||
defer func() {
|
||||
_ = temporary.Close()
|
||||
if removeTemporary {
|
||||
_ = os.Remove(temporaryPath)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(installedProfileFileMode); err != nil {
|
||||
return "", fmt.Errorf("protect temporary carrier profile: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(result.Document); err != nil {
|
||||
return "", fmt.Errorf("write temporary carrier profile: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return "", fmt.Errorf("sync temporary carrier profile: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return "", fmt.Errorf("close temporary carrier profile: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, target); err != nil {
|
||||
return "", fmt.Errorf("install carrier profile %q: %w", target, err)
|
||||
}
|
||||
removeTemporary = false
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func carrierBundleRoots(files []*zip.File) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, file := range files {
|
||||
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
||||
if strings.Contains(strings.ToLower(name), "/signatures/") ||
|
||||
!strings.EqualFold(path.Base(name), "carrier.plist") {
|
||||
continue
|
||||
}
|
||||
root := path.Dir(name)
|
||||
if root == "." || root == "/" {
|
||||
continue
|
||||
}
|
||||
seen[root] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for root := range seen {
|
||||
result = append(result, root)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func selectCarrierBundle(roots []string, wanted string) (string, error) {
|
||||
if len(roots) == 0 {
|
||||
return "", errors.New("IPCC contains no carrier.plist bundle")
|
||||
}
|
||||
wanted = strings.TrimSpace(strings.ReplaceAll(wanted, "\\", "/"))
|
||||
if wanted != "" {
|
||||
for _, root := range roots {
|
||||
base := path.Base(root)
|
||||
if strings.EqualFold(root, wanted) || strings.EqualFold(base, wanted) ||
|
||||
strings.EqualFold(strings.TrimSuffix(base, path.Ext(base)), strings.TrimSuffix(wanted, path.Ext(wanted))) {
|
||||
return root, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("carrier bundle %q not found; choices: %s", wanted, strings.Join(roots, ", "))
|
||||
}
|
||||
if len(roots) != 1 {
|
||||
return "", fmt.Errorf("IPCC contains multiple carrier bundles; select one with --bundle: %s", strings.Join(roots, ", "))
|
||||
}
|
||||
return roots[0], nil
|
||||
}
|
||||
|
||||
func readCarrierBundlePlists(files []*zip.File, root string) ([]ipccPlist, error) {
|
||||
var primary *zip.File
|
||||
overrides := make([]*zip.File, 0)
|
||||
rootPrefix := strings.TrimSuffix(root, "/") + "/"
|
||||
for _, file := range files {
|
||||
name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/"))
|
||||
if !strings.HasPrefix(name, rootPrefix) || strings.Contains(strings.ToLower(name), "/signatures/") {
|
||||
continue
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case strings.EqualFold(name, rootPrefix+"carrier.plist"):
|
||||
primary = file
|
||||
case strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist"):
|
||||
overrides = append(overrides, file)
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return nil, fmt.Errorf("bundle %q has no carrier.plist", root)
|
||||
}
|
||||
sort.Slice(overrides, func(i, j int) bool { return overrides[i].Name < overrides[j].Name })
|
||||
selected := append([]*zip.File{primary}, overrides...)
|
||||
result := make([]ipccPlist, 0, len(selected))
|
||||
var total uint64
|
||||
for _, file := range selected {
|
||||
if file.UncompressedSize64 > maxIPCCPlistBytes {
|
||||
return nil, fmt.Errorf("plist %q exceeds %d bytes", file.Name, maxIPCCPlistBytes)
|
||||
}
|
||||
total += file.UncompressedSize64
|
||||
if total > maxIPCCPlistTotalBytes {
|
||||
return nil, fmt.Errorf("selected plists exceed %d uncompressed bytes", maxIPCCPlistTotalBytes)
|
||||
}
|
||||
root, err := decodeIPCCPlist(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode plist %q: %w", file.Name, err)
|
||||
}
|
||||
result = append(result, ipccPlist{name: file.Name, root: root})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeIPCCPlist(file *zip.File) (map[string]any, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
encoded, err := io.ReadAll(io.LimitReader(reader, maxIPCCPlistBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(encoded) > maxIPCCPlistBytes {
|
||||
return nil, fmt.Errorf("plist exceeds %d bytes", maxIPCCPlistBytes)
|
||||
}
|
||||
decoder := plist.NewDecoder(bytes.NewReader(encoded))
|
||||
var root map[string]any
|
||||
if err := decoder.Decode(&root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if root == nil {
|
||||
return nil, errors.New("plist root is not a dictionary")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func importCarrierSelectors(primary map[string]any, plists []ipccPlist, warnings *ipccWarningSet) ([]carrierProfileMatch, []string, error) {
|
||||
supportedSIMs := plistStrings(primary["SupportedSIMs"])
|
||||
supportedPLMNs := normalizedPLMNs(plistStrings(primary["SupportedPLMNs"]))
|
||||
plainPLMNs := make([]string, 0)
|
||||
qualified := make([]carrierProfileMatch, 0)
|
||||
for _, raw := range supportedSIMs {
|
||||
match, constrained, valid := parseAppleSupportedSIM(raw, warnings)
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
if constrained {
|
||||
qualified = append(qualified, match)
|
||||
} else {
|
||||
plainPLMNs = append(plainPLMNs, match.HomePLMNs...)
|
||||
}
|
||||
}
|
||||
|
||||
allPLMNs := normalizeIPCCStringList(append(append([]string(nil), plainPLMNs...), supportedPLMNs...), false)
|
||||
matches := qualified
|
||||
if len(matches) == 0 {
|
||||
if len(allPLMNs) == 0 {
|
||||
return nil, nil, errors.New("no supported MCC/MNC selector was found")
|
||||
}
|
||||
match := carrierProfileMatch{HomePLMNs: allPLMNs}
|
||||
iccidPrefixes := collectMatchingICCIDPrefixes(plists)
|
||||
if len(iccidPrefixes) > 0 {
|
||||
match.ICCIDPrefixes = iccidPrefixes
|
||||
warnings.add(
|
||||
"remote_provisioning_iccid_selector",
|
||||
"MatchingICCIDPrefixes was used only because the bundle has no GID/SPN selector; verify that it identifies subscriptions rather than only eSIM provisioning eligibility",
|
||||
"RemoteCardProvisioningSettings.MatchingICCIDPrefixes",
|
||||
)
|
||||
} else {
|
||||
warnings.add(
|
||||
"broad_plmn_selector",
|
||||
"the generated rule matches a whole home PLMN because the bundle exposes no GID, SPN, or ICCID discriminator",
|
||||
"SupportedSIMs",
|
||||
)
|
||||
}
|
||||
matches = []carrierProfileMatch{match}
|
||||
}
|
||||
matches = deduplicateCarrierMatches(matches)
|
||||
if len(matches) == 0 {
|
||||
return nil, nil, errors.New("all SupportedSIMs selectors were unsupported")
|
||||
}
|
||||
if len(allPLMNs) == 0 {
|
||||
for _, match := range matches {
|
||||
allPLMNs = append(allPLMNs, match.HomePLMNs...)
|
||||
}
|
||||
allPLMNs = normalizeIPCCStringList(allPLMNs, false)
|
||||
}
|
||||
return matches, allPLMNs, nil
|
||||
}
|
||||
|
||||
func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfileMatch, bool, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parts := strings.Split(raw, "_")
|
||||
if len(parts) == 0 || !supportedSIMPLMN.MatchString(parts[0]) || canonicalPLMNValue(parts[0]) == "" {
|
||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs value "+strconv.Quote(raw), "SupportedSIMs")
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
match := carrierProfileMatch{HomePLMNs: []string{parts[0]}}
|
||||
for _, qualifier := range parts[1:] {
|
||||
name, value, found := strings.Cut(qualifier, "-")
|
||||
value = strings.TrimSpace(value)
|
||||
if !found || value == "" {
|
||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(qualifier), "SupportedSIMs")
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "GID1":
|
||||
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value))
|
||||
case "GID2":
|
||||
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value))
|
||||
case "ICCID":
|
||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, strings.TrimRight(value, "Ff"))
|
||||
case "SPN":
|
||||
match.SPNs = append(match.SPNs, value)
|
||||
default:
|
||||
warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(name), "SupportedSIMs")
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
}
|
||||
return match, len(parts) > 1, true
|
||||
}
|
||||
|
||||
func trimAppleHexMask(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
trimmed := strings.TrimRight(value, "F")
|
||||
if trimmed == "" {
|
||||
return value
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
||||
values := make([]string, 0)
|
||||
for _, document := range plists {
|
||||
walkPlist(document.root, nil, func(path []string, value any) {
|
||||
if len(path) == 0 || !strings.EqualFold(path[len(path)-1], "MatchingICCIDPrefixes") {
|
||||
return
|
||||
}
|
||||
for _, prefix := range plistStrings(value) {
|
||||
prefix = strings.TrimRight(strings.TrimSpace(prefix), "Ff")
|
||||
if len(prefix) >= 5 && decimalString(prefix) {
|
||||
values = append(values, prefix)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return normalizeIPCCStringList(values, false)
|
||||
}
|
||||
|
||||
func importCarrierEPDG(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
addresses := make(map[string][]string)
|
||||
for _, document := range plists {
|
||||
for _, ike := range dictionariesForKey(document.root, "IKE") {
|
||||
address := strings.ToLower(strings.TrimSuffix(plistString(ike.value["RemoteAddress"]), "."))
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
if !validEPDGHostname(address) {
|
||||
warnings.add("unsupported_epdg_address", "ignored non-ePDG IKE RemoteAddress "+strconv.Quote(address), document.name+":"+strings.Join(ike.path, "."))
|
||||
continue
|
||||
}
|
||||
addresses[address] = append(addresses[address], document.name)
|
||||
}
|
||||
}
|
||||
keys := sortedMapKeys(addresses)
|
||||
switch len(keys) {
|
||||
case 0:
|
||||
warnings.add("epdg_not_explicit", "no unambiguous ePDG RemoteAddress was found; VoCat will derive the standard 3GPP hostname from the matched PLMN", "TechSettings.IKE.RemoteAddress")
|
||||
case 1:
|
||||
rule.EPDG.Hostname = keys[0]
|
||||
default:
|
||||
warnings.add("conflicting_epdg", "device override plists disagree on ePDG RemoteAddress; no address was imported: "+strings.Join(keys, ", "), "TechSettings.IKE.RemoteAddress")
|
||||
}
|
||||
}
|
||||
|
||||
func importCarrierIKE(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
groups := make(map[int]struct{})
|
||||
eapMethods := make(map[string]struct{})
|
||||
for _, document := range plists {
|
||||
for _, located := range dictionariesForKey(document.root, "IKE") {
|
||||
ike := located.value
|
||||
for _, proposal := range plistDictionaries(ike["Proposals"]) {
|
||||
if group, ok := plistInt(proposal["DHGroup"]); ok {
|
||||
groups[group] = struct{}{}
|
||||
}
|
||||
if method := strings.ToUpper(plistString(proposal["EAPMethod"])); method != "" {
|
||||
eapMethods[method] = struct{}{}
|
||||
}
|
||||
}
|
||||
if validate, ok := plistBool(ike["ValidateRemoteCertificate"]); ok && !validate {
|
||||
warnings.add("remote_certificate_bypass_ignored", "ValidateRemoteCertificate=false was not imported", document.name+":"+strings.Join(located.path, ".")+".ValidateRemoteCertificate")
|
||||
}
|
||||
if enabled, ok := plistBool(ike["DeadPeerDetectionEnabled"]); ok {
|
||||
if !enabled {
|
||||
warnings.add("disabled_dpd_ignored", "Apple disables DPD for this device family; VoCat keeps its safe liveness defaults", document.name+":"+strings.Join(located.path, ".")+".DeadPeerDetectionEnabled")
|
||||
} else if _, hasInterval := ike["DeadPeerDetectionInterval"]; hasInterval {
|
||||
warnings.add("dpd_override_ignored", "device-specific DPD timing was not imported; VoCat keeps its runtime defaults", document.name+":"+strings.Join(located.path, "."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
unknown := make([]string, 0)
|
||||
_, hasModern := groups[14]
|
||||
_, hasLegacy := groups[2]
|
||||
for group := range groups {
|
||||
if group != 2 && group != 14 {
|
||||
unknown = append(unknown, strconv.Itoa(group))
|
||||
}
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
switch {
|
||||
case len(unknown) > 0:
|
||||
warnings.add("unsupported_ike_group", "unsupported IKE DH group(s) were not imported: "+strings.Join(unknown, ", "), "TechSettings.IKE.Proposals")
|
||||
case hasModern:
|
||||
rule.IKE.Proposal = IKEProposalModern
|
||||
case hasLegacy:
|
||||
rule.IKE.Proposal = IKEProposalLegacy
|
||||
}
|
||||
}
|
||||
for method := range eapMethods {
|
||||
if method != "EAP-AKA" && method != "EAP-AKA'" {
|
||||
warnings.add("unsupported_eap_method", "VoCat does not import Apple EAP method "+strconv.Quote(method), "TechSettings.IKE.Proposals.EAPMethod")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
useIPSec := false
|
||||
for _, document := range plists {
|
||||
for _, signaling := range dictionariesForKey(document.root, "Signaling") {
|
||||
if value, ok := plistBool(signaling.value["UseIPSec"]); ok {
|
||||
if value {
|
||||
useIPSec = true
|
||||
} else {
|
||||
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if useIPSec {
|
||||
// Apple does not describe the negotiated ESP algorithm in a portable
|
||||
// field. Keep VoCat's safe AES-CBC default while recording the intent.
|
||||
rule.IMS.IPSecEncryption = "aes-cbc"
|
||||
}
|
||||
}
|
||||
|
||||
func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
||||
for _, document := range plists {
|
||||
walkPlist(document.root, nil, func(keyPath []string, value any) {
|
||||
if len(keyPath) == 0 {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(keyPath[len(keyPath)-1])
|
||||
fullPath := document.name + ":" + strings.Join(keyPath, ".")
|
||||
switch {
|
||||
case key == "enablewificallingwithoutentitlement":
|
||||
if enabled, ok := plistBool(value); ok && enabled {
|
||||
warnings.add("entitlement_bypass_ignored", "Wi-Fi Calling entitlement bypass was not imported", fullPath)
|
||||
}
|
||||
case key == "apns":
|
||||
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
||||
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
||||
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
||||
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
||||
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type locatedDictionary struct {
|
||||
path []string
|
||||
value map[string]any
|
||||
}
|
||||
|
||||
func dictionariesForKey(root map[string]any, wanted string) []locatedDictionary {
|
||||
result := make([]locatedDictionary, 0)
|
||||
walkPlist(root, nil, func(keyPath []string, value any) {
|
||||
if len(keyPath) == 0 || !strings.EqualFold(keyPath[len(keyPath)-1], wanted) {
|
||||
return
|
||||
}
|
||||
if dictionary, ok := value.(map[string]any); ok {
|
||||
result = append(result, locatedDictionary{path: append([]string(nil), keyPath...), value: dictionary})
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func walkPlist(value any, keyPath []string, visit func([]string, any)) {
|
||||
visit(keyPath, value)
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
walkPlist(typed[key], appendPath(keyPath, key), visit)
|
||||
}
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
walkPlist(item, appendPath(keyPath, strconv.Itoa(index)), visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendPath(base []string, item string) []string {
|
||||
result := make([]string, len(base), len(base)+1)
|
||||
copy(result, base)
|
||||
return append(result, item)
|
||||
}
|
||||
|
||||
func plistStrings(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
return []string{strings.TrimSpace(typed)}
|
||||
}
|
||||
case []any:
|
||||
result := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if value := plistString(item); value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []string:
|
||||
return normalizeIPCCStringList(typed, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeIPCCStringList(values []string, lower bool) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if lower {
|
||||
value = strings.ToLower(value)
|
||||
}
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func plistDictionaries(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return []map[string]any{typed}
|
||||
case []any:
|
||||
result := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if dictionary, ok := item.(map[string]any); ok {
|
||||
result = append(result, dictionary)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func plistString(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func plistBool(value any) (bool, bool) {
|
||||
result, ok := value.(bool)
|
||||
return result, ok
|
||||
}
|
||||
|
||||
func plistInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), int64(int(typed)) == typed
|
||||
case uint64:
|
||||
return int(typed), uint64(int(typed)) == typed
|
||||
case float64:
|
||||
return int(typed), float64(int(typed)) == typed
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedPLMNs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if supportedSIMPLMN.MatchString(value) && canonicalPLMNValue(value) != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return normalizeIPCCStringList(result, false)
|
||||
}
|
||||
|
||||
func deduplicateCarrierMatches(matches []carrierProfileMatch) []carrierProfileMatch {
|
||||
result := make([]carrierProfileMatch, 0, len(matches))
|
||||
seen := make(map[string]struct{})
|
||||
for _, match := range matches {
|
||||
encoded, _ := json.Marshal(match)
|
||||
key := string(encoded)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, match)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func statusBarCarrierName(root map[string]any) string {
|
||||
for _, item := range plistDictionaries(root["StatusBarImages"]) {
|
||||
if name := firstNonempty(plistString(item["CarrierName"]), plistString(item["StatusBarCarrierName"])); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generatedIPCCProfileID(carrierName string, plmns []string) string {
|
||||
base := slugCarrierProfileID(carrierName)
|
||||
if base == "" {
|
||||
base = "carrier"
|
||||
}
|
||||
if len(plmns) > 0 {
|
||||
base += "-" + plmns[0]
|
||||
}
|
||||
base = "ipcc-" + base
|
||||
if len(base) > 64 {
|
||||
base = strings.TrimRight(base[:64], "-._")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func slugCarrierProfileID(value string) string {
|
||||
var result strings.Builder
|
||||
separator := false
|
||||
for _, item := range strings.ToLower(strings.TrimSpace(value)) {
|
||||
switch {
|
||||
case item >= 'a' && item <= 'z', item >= '0' && item <= '9':
|
||||
if separator && result.Len() > 0 {
|
||||
result.WriteByte('-')
|
||||
}
|
||||
result.WriteRune(item)
|
||||
separator = false
|
||||
case unicode.IsSpace(item), item == '-', item == '_', item == '.':
|
||||
separator = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(result.String(), "-")
|
||||
}
|
||||
|
||||
func validInstalledProfileID(value string) bool {
|
||||
if len(value) < 1 || len(value) > 64 || !asciiLowerOrDigit(rune(value[0])) {
|
||||
return false
|
||||
}
|
||||
for _, item := range value {
|
||||
if asciiLowerOrDigit(item) || item == '-' || item == '_' || item == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func asciiLowerOrDigit(item rune) bool {
|
||||
return item >= 'a' && item <= 'z' || item >= '0' && item <= '9'
|
||||
}
|
||||
|
||||
func validEPDGHostname(value string) bool {
|
||||
if len(value) < 4 || len(value) > 253 || !strings.Contains(strings.ToLower(value), "epdg") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(value, ".") {
|
||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, item := range label {
|
||||
if item >= 'a' && item <= 'z' || item >= '0' && item <= '9' || item == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func consensusPositiveInt(values []int) (int, bool) {
|
||||
if len(values) == 0 || values[0] <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
for _, value := range values[1:] {
|
||||
if value != values[0] {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
func sortedMapKeys[T any](values map[string]T) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
result = append(result, key)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNonempty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
type testIPCCPlist struct {
|
||||
value map[string]any
|
||||
format int
|
||||
}
|
||||
|
||||
func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||
"Payload/O2_Giffgaff_UK.bundle/carrier.plist": {
|
||||
format: plist.XMLFormat,
|
||||
value: map[string]any{
|
||||
"CarrierName": "giffgaff",
|
||||
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
||||
"SupportedPLMNs": []any{"23410"},
|
||||
"apns": []any{map[string]any{"apn": "giffgaff.com"}},
|
||||
},
|
||||
},
|
||||
"Payload/O2_Giffgaff_UK.bundle/overrides_D1.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{
|
||||
"TechSettings": map[string]any{
|
||||
"IKE": map[string]any{
|
||||
"RemoteAddress": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org",
|
||||
"ValidateRemoteCertificate": false,
|
||||
"DeadPeerDetectionEnabled": false,
|
||||
"Proposals": []any{map[string]any{
|
||||
"DHGroup": 14, "EAPMethod": "EAP-AKA",
|
||||
}},
|
||||
},
|
||||
},
|
||||
"IMSConfig": map[string]any{
|
||||
"EnableWiFiCallingWithoutEntitlement": true,
|
||||
"Signaling": map[string]any{"UseIPSec": true},
|
||||
"Media": map[string]any{"SupportPCMA": false},
|
||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CarrierName != "giffgaff" || result.ProfileID != "ipcc-giffgaff-23410" || result.SourceSHA256 == "" {
|
||||
t.Fatalf("import metadata = %#v", result)
|
||||
}
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(result.Document, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document.Version != CarrierProfileSchemaVersion || len(document.Profiles) != 1 {
|
||||
t.Fatalf("document = %#v", document)
|
||||
}
|
||||
rule := document.Profiles[0]
|
||||
if rule.Match.HomePLMNs[0] != "23410" || rule.Match.GID1Prefixes[0] != "508" {
|
||||
t.Fatalf("converted selector = %#v", rule.Match)
|
||||
}
|
||||
if rule.EPDG.Hostname != "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org" || rule.IKE.Proposal != IKEProposalModern {
|
||||
t.Fatalf("converted IKE profile = %#v", rule)
|
||||
}
|
||||
if rule.IMS.IPSecEncryption != "aes-cbc" {
|
||||
t.Fatalf("converted IMS profile = %#v", rule.IMS)
|
||||
}
|
||||
for _, code := range []string{
|
||||
"remote_certificate_bypass_ignored",
|
||||
"disabled_dpd_ignored",
|
||||
"entitlement_bypass_ignored",
|
||||
"apn_settings_ignored",
|
||||
"device_media_overrides_ignored",
|
||||
"emergency_settings_ignored",
|
||||
} {
|
||||
if !hasIPCCWarning(result.Warnings, code) {
|
||||
t.Errorf("missing warning %q: %#v", code, result.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCarrierIPCCRejectsAmbiguousBundleAndConflictingEPDG(t *testing.T) {
|
||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||
"Payload/One.bundle/carrier.plist": {
|
||||
format: plist.XMLFormat,
|
||||
value: map[string]any{"CarrierName": "One", "SupportedSIMs": []any{"99901"}},
|
||||
},
|
||||
"Payload/One.bundle/overrides_A.plist": {
|
||||
format: plist.XMLFormat,
|
||||
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.one.example"}}},
|
||||
},
|
||||
"Payload/One.bundle/overrides_B.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.two.example"}}},
|
||||
},
|
||||
"Payload/Two.bundle/carrier.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{"CarrierName": "Two", "SupportedSIMs": []any{"99902"}},
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{}); err == nil {
|
||||
t.Fatal("multi-bundle IPCC imported without --bundle")
|
||||
}
|
||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{Bundle: "One"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(result.Document, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document.Profiles[0].EPDG.Hostname != "" || !hasIPCCWarning(result.Warnings, "conflicting_epdg") {
|
||||
t.Fatalf("conflicting ePDG was not quarantined: %#v, %#v", document.Profiles[0], result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCarrierIPCCResultLoadsExternalProfileAtEqualSpecificity(t *testing.T) {
|
||||
archivePath := writeTestIPCC(t, map[string]testIPCCPlist{
|
||||
"Payload/Test.bundle/carrier.plist": {
|
||||
format: plist.BinaryFormat,
|
||||
value: map[string]any{
|
||||
"CarrierName": "Installed Test",
|
||||
"SupportedSIMs": []any{"23410_GID1-508FFFFF"},
|
||||
"SupportedPLMNs": []any{"23410"},
|
||||
},
|
||||
},
|
||||
})
|
||||
result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{ProfileID: "installed-giffgaff-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
emptyDir := t.TempDir()
|
||||
t.Cleanup(func() {
|
||||
if err := LoadCarrierProfileDirectory(emptyDir); err != nil {
|
||||
t.Errorf("clear external profiles: %v", err)
|
||||
}
|
||||
})
|
||||
target, err := InstallCarrierIPCCResult(result, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(target) != "installed-giffgaff-test.json" {
|
||||
t.Fatalf("installed path = %q", target)
|
||||
}
|
||||
if _, err := InstallCarrierIPCCResult(result, dir); err == nil {
|
||||
t.Fatal("second install overwrote an existing profile")
|
||||
}
|
||||
if err := LoadCarrierProfileDirectory(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"})
|
||||
if profile.ID != "installed-giffgaff-test" {
|
||||
t.Fatalf("installed equal-specificity profile did not override builtin: %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestIPCC(t *testing.T, files map[string]testIPCCPlist) string {
|
||||
t.Helper()
|
||||
archivePath := filepath.Join(t.TempDir(), "carrier.ipcc")
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(file)
|
||||
for name, item := range files {
|
||||
var encoded bytes.Buffer
|
||||
if err := plist.NewEncoderForFormat(&encoded, item.format).Encode(item.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := archive.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write(encoded.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return archivePath
|
||||
}
|
||||
|
||||
func hasIPCCWarning(warnings []IPCCImportWarning, code string) bool {
|
||||
for _, warning := range warnings {
|
||||
if warning.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"id": "xesim-lebara-vodafone-uk",
|
||||
"match": {
|
||||
"home_plmns": ["20404"],
|
||||
"imsi_prefixes": ["204047"],
|
||||
"iccid_prefixes": ["894416"]
|
||||
},
|
||||
"route": { "mcc": "234", "mnc": "15" },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||
},
|
||||
{
|
||||
"id": "ee-uk-hosted-23433",
|
||||
"match": {
|
||||
"home_plmns": ["23433"],
|
||||
"imsi_prefixes": ["23433"],
|
||||
"iccid_prefixes": ["894430"]
|
||||
},
|
||||
"route": { "mcc": "234", "mnc": "30" },
|
||||
"ims": {
|
||||
"pani_country": "GB",
|
||||
"pani_node": "000000000000",
|
||||
"dial_uri_scheme": "sip",
|
||||
"voice_codecs": ["PCMA", "PCMU"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "att-us",
|
||||
"match": {
|
||||
"home_plmns": ["310280"],
|
||||
"imsi_prefixes": ["310280"]
|
||||
},
|
||||
"epdg": { "hostname": "epdg.epc.att.net" },
|
||||
"ims": {
|
||||
"identity_profile": "att",
|
||||
"register_profile": "att",
|
||||
"ipsec_encryption": "aes-cbc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "o2-germany",
|
||||
"match": { "home_plmns": ["26203"] },
|
||||
"ike": { "advertise_eap_only": false },
|
||||
"ims": {
|
||||
"register_profile": "o2-germany",
|
||||
"ipsec_encryption": "null"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vodafone-uk",
|
||||
"match": { "home_plmns": ["23415"] },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" },
|
||||
"ims": { "sms_center": "+447785016005" }
|
||||
},
|
||||
{
|
||||
"id": "vodafone-netherlands",
|
||||
"match": { "home_plmns": ["20404"] },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||
},
|
||||
{
|
||||
"id": "giffgaff-o2-uk",
|
||||
"match_any": [
|
||||
{
|
||||
"home_plmns": ["23410"],
|
||||
"gid1_prefixes": ["508"]
|
||||
},
|
||||
{
|
||||
"home_plmns": ["23410"],
|
||||
"spns": ["giffgaff"]
|
||||
}
|
||||
],
|
||||
"ims": {
|
||||
"transport": "udp",
|
||||
"sms_center": "+447802002606",
|
||||
"user_eq_phone": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "o2-uk",
|
||||
"match": { "home_plmns": ["23410"] },
|
||||
"ims": {
|
||||
"transport": "udp",
|
||||
"sms_center": "+447802000332",
|
||||
"user_eq_phone": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vodafone-germany",
|
||||
"match": { "home_plmns": ["26202"] },
|
||||
"epdg": {
|
||||
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
|
||||
"dns_client_subnet": "109.192.0.0/24"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -173,6 +173,13 @@ func (adapter *EC20Adapter) ReadIdentity(
|
||||
HomeMCC: homeMCC,
|
||||
HomeMNC: homeMNC,
|
||||
}
|
||||
if reader, ok := adapter.executor.(SIMMetadataReader); ok {
|
||||
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||
}
|
||||
}
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[iccid] = ec20SIMBinding{
|
||||
|
||||
@@ -10,19 +10,12 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const googleDNSOverHTTPS = "https://dns.google/resolve"
|
||||
|
||||
// A small number of operators publish the standard ePDG CNAME globally but
|
||||
// return its A records only when the recursive DNS query appears to originate
|
||||
// in the home country. Keep this list deliberately narrow: ordinary ePDGs must
|
||||
// continue to use the host resolver, and a fallback is attempted only after
|
||||
// that resolver has failed.
|
||||
var geoRestrictedEPDGSubnets = map[string]string{
|
||||
"epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany
|
||||
}
|
||||
|
||||
type dnsOverHTTPSResponse struct {
|
||||
Status int `json:"Status"`
|
||||
Answer []struct {
|
||||
@@ -41,7 +34,7 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
subnet := geoRestrictedEPDGSubnets[normalized]
|
||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||
if subnet == "" {
|
||||
if systemErr != nil {
|
||||
return nil, systemErr
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,17 +21,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Random io.Reader
|
||||
Resolver *net.Resolver
|
||||
Dialer *net.Dialer
|
||||
RootCAs *x509.CertPool
|
||||
ResponderPublicKey crypto.PublicKey
|
||||
ServerName string
|
||||
Timeout time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
Installer ChildSAInstaller
|
||||
IdentityType uint8
|
||||
APN string
|
||||
Random io.Reader
|
||||
Resolver *net.Resolver
|
||||
Dialer *net.Dialer
|
||||
RootCAs *x509.CertPool
|
||||
ResponderPublicKey crypto.PublicKey
|
||||
ServerName string
|
||||
Timeout time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
Installer ChildSAInstaller
|
||||
IdentityType uint8
|
||||
APN string
|
||||
AutoProposalFallback bool
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -42,6 +45,9 @@ func NewProvider(config Config) (*Provider, error) {
|
||||
if config.Random == nil {
|
||||
config.Random = rand.Reader
|
||||
}
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
if config.Resolver == nil {
|
||||
config.Resolver = net.DefaultResolver
|
||||
}
|
||||
@@ -77,6 +83,30 @@ func NewProvider(config Config) (*Provider, error) {
|
||||
}
|
||||
|
||||
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
|
||||
if provider == nil {
|
||||
return nil, errors.New("ike: nil provider")
|
||||
}
|
||||
session, err := provider.start(ctx, request, false)
|
||||
if err == nil || !provider.config.AutoProposalFallback {
|
||||
return session, err
|
||||
}
|
||||
profile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||
if profile.ID != vowifi.CarrierProfileStandard || !retryableLegacyProposal(err) {
|
||||
return nil, err
|
||||
}
|
||||
provider.config.Logger.Warn("IKE ePDG rejected modern proposal; trying bounded legacy fallback",
|
||||
"carrier_profile", profile.ID, "from_proposal", vowifi.IKEProposalModern,
|
||||
"to_proposal", vowifi.IKEProposalLegacy, "error", err)
|
||||
session, fallbackErr := provider.start(ctx, request, true)
|
||||
if fallbackErr != nil {
|
||||
return nil, errors.Join(err, fmt.Errorf("ike: legacy proposal fallback failed: %w", fallbackErr))
|
||||
}
|
||||
provider.config.Logger.Info("IKE automatic legacy proposal fallback succeeded",
|
||||
"carrier_profile", profile.ID, "proposal", vowifi.IKEProposalLegacy)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) start(ctx context.Context, request vowifi.TunnelRequest, forceLegacy bool) (vowifi.TunnelSession, error) {
|
||||
if provider == nil {
|
||||
return nil, errors.New("ike: nil provider")
|
||||
}
|
||||
@@ -110,8 +140,12 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
}()
|
||||
|
||||
group := uint16(dhMODP2048)
|
||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
carrierProfile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||
legacyFirst := carrierProfile.IKEProposal == vowifi.IKEProposalLegacy
|
||||
if forceLegacy {
|
||||
legacyFirst = true
|
||||
}
|
||||
advertiseEAPOnly := carrierProfile.AdvertiseEAPOnly
|
||||
if legacyFirst {
|
||||
group = dhMODP1024
|
||||
}
|
||||
@@ -582,30 +616,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func legacyIKEProfile(mcc, mnc string) bool {
|
||||
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
|
||||
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
|
||||
// that Vodafone NL core; treating them as a generic modern network causes
|
||||
// IKE_SA_INIT to fail before EAP-AKA even begins.
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "23415" || plmn == "2044"
|
||||
}
|
||||
|
||||
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
|
||||
// Android exposes the ePDG authentication method as carrier policy rather
|
||||
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
|
||||
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
|
||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
||||
}
|
||||
|
||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "2623"
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
@@ -719,6 +729,27 @@ func decryptAndValidate(
|
||||
return header, payloads, nil
|
||||
}
|
||||
|
||||
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||
|
||||
type invalidKEPayloadError struct {
|
||||
group uint16
|
||||
}
|
||||
|
||||
func (err *invalidKEPayloadError) Error() string {
|
||||
if err.group != 0 {
|
||||
return fmt.Sprintf("ike: responder requires DH group %d", err.group)
|
||||
}
|
||||
return "ike: responder reported INVALID_KE_PAYLOAD"
|
||||
}
|
||||
|
||||
func retryableLegacyProposal(err error) bool {
|
||||
if errors.Is(err, errNoProposalChosen) {
|
||||
return true
|
||||
}
|
||||
var invalidKE *invalidKEPayloadError
|
||||
return errors.As(err, &invalidKE) && (invalidKE.group == 0 || invalidKE.group == dhMODP1024)
|
||||
}
|
||||
|
||||
func rejectFatalNotifications(payloads []payload) error {
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
@@ -727,12 +758,12 @@ func rejectFatalNotifications(payloads []payload) error {
|
||||
}
|
||||
switch kind {
|
||||
case notifyNoProposal:
|
||||
return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||
return errNoProposalChosen
|
||||
case notifyInvalidKE:
|
||||
if len(data) == 2 {
|
||||
return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data))
|
||||
return &invalidKEPayloadError{group: binary.BigEndian.Uint16(data)}
|
||||
}
|
||||
return errors.New("ike: responder reported INVALID_KE_PAYLOAD")
|
||||
return &invalidKEPayloadError{}
|
||||
}
|
||||
if kind < 16384 {
|
||||
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
|
||||
|
||||
@@ -25,15 +25,37 @@ func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
||||
{mcc: "204", mnc: "04"},
|
||||
{mcc: "204", mnc: "004"},
|
||||
} {
|
||||
if !legacyIKEProfile(item.mcc, item.mnc) {
|
||||
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
|
||||
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
||||
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
||||
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
||||
}
|
||||
}
|
||||
if legacyIKEProfile("234", "87") {
|
||||
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errNoProposalChosen,
|
||||
&invalidKEPayloadError{},
|
||||
&invalidKEPayloadError{group: dhMODP1024},
|
||||
} {
|
||||
if !retryableLegacyProposal(err) {
|
||||
t.Errorf("negotiation failure %v was not retryable", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{
|
||||
&invalidKEPayloadError{group: dhMODP2048},
|
||||
errors.New("ike: authentication failed"),
|
||||
vowifi.ErrEAPAuthenticationRejected,
|
||||
} {
|
||||
if retryableLegacyProposal(err) {
|
||||
t.Errorf("unsafe failure %v enabled legacy retry", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
for index := range destination {
|
||||
destination[index] = reader.value
|
||||
|
||||
@@ -171,11 +171,12 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
||||
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
||||
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
||||
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,30 @@ var (
|
||||
|
||||
const terminalCallRetention = 30 * time.Second
|
||||
|
||||
const (
|
||||
mmtelServiceURN = "urn:urn-7:3gpp-service.ims.icsi.mmtel"
|
||||
mmtelFeatureTag = "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"
|
||||
)
|
||||
|
||||
type imsCall struct {
|
||||
public vowifi.Call
|
||||
callID string
|
||||
target string
|
||||
from string
|
||||
to string
|
||||
branch string
|
||||
cseq uint32
|
||||
invite *sipRequest
|
||||
respond func([]byte) error
|
||||
responses chan *sipResponse
|
||||
remoteTag string
|
||||
routes []string
|
||||
terminated bool
|
||||
media *rtpMedia
|
||||
public vowifi.Call
|
||||
callID string
|
||||
target string
|
||||
from string
|
||||
to string
|
||||
branch string
|
||||
cseq uint32
|
||||
inviteTarget string
|
||||
invite *sipRequest
|
||||
respond func([]byte) error
|
||||
responses chan *sipResponse
|
||||
remoteTag string
|
||||
routes []string
|
||||
terminated bool
|
||||
media *rtpMedia
|
||||
pracked map[string]bool
|
||||
sessionExpires int
|
||||
sessionCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (session *Session) Calls() []vowifi.Call {
|
||||
@@ -67,12 +76,14 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
callID := callToken + "@" + addressHost(session.conn.LocalAddr())
|
||||
target := "tel:" + number
|
||||
carrierProfile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
target := callTargetURI(number, session.identity.domain, carrierProfile)
|
||||
session.mu.Lock()
|
||||
cseq := session.cseq
|
||||
session.cseq++
|
||||
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
fromIdentity, preferredIdentity, identitySource := session.callOriginatingIdentitiesLocked(carrierProfile)
|
||||
session.mu.Unlock()
|
||||
media, err := newRTPMedia(session.localMediaIP())
|
||||
if err != nil {
|
||||
@@ -80,7 +91,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
}
|
||||
body := media.offerSDP(session.localMediaIP())
|
||||
transportUpper := strings.ToUpper(session.transport)
|
||||
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
||||
from := "<" + fromIdentity + ">;tag=" + session.fromTag
|
||||
to := "<" + target + ">"
|
||||
lines := []string{
|
||||
"INVITE " + target + " SIP/2.0",
|
||||
@@ -100,10 +111,17 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
"To: "+to,
|
||||
"Call-ID: "+callID,
|
||||
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
||||
"Contact: <sip:"+session.identity.user+"@"+session.contactAddress()+";transport="+session.transport+">",
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE",
|
||||
"Supported: timer",
|
||||
session.dialogContactHeader(),
|
||||
"P-Preferred-Identity: <"+preferredIdentity+">",
|
||||
"P-Preferred-Service: "+mmtelServiceURN,
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
|
||||
"Supported: 100rel, timer, replaces",
|
||||
"Session-Expires: 1800;refresher=uac",
|
||||
"Min-SE: 90",
|
||||
"Accept: application/sdp",
|
||||
"Content-Type: application/sdp",
|
||||
"Content-Length: "+strconv.Itoa(len(body)), "", "",
|
||||
)
|
||||
@@ -120,12 +138,19 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
session.transactionsMu.Unlock()
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
||||
callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||
routes: routes, media: media,
|
||||
callID: callID, target: target, inviteTarget: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||
routes: routes, media: media, pracked: make(map[string]bool),
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Info("IMS call started",
|
||||
"direction", "outgoing",
|
||||
"identity_source", identitySource,
|
||||
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||
)
|
||||
}
|
||||
session.writeMu.Lock()
|
||||
_, err = session.conn.Write(request)
|
||||
session.writeMu.Unlock()
|
||||
@@ -166,24 +191,26 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
if response == nil {
|
||||
continue
|
||||
}
|
||||
diagnostic := callResponseDiagnostic(response)
|
||||
session.logCallResponse(response, diagnostic)
|
||||
if response.StatusCode < 200 {
|
||||
session.setCallDiagnostic(call.callID, response.StatusCode, response.Reason)
|
||||
if response.StatusCode >= 180 {
|
||||
session.setCallDiagnostic(call.callID, response.StatusCode, diagnostic)
|
||||
session.updateCallDialogFromResponse(call, response)
|
||||
if len(response.Body) > 0 {
|
||||
if mediaErr := call.media.configureRemote(response.Body); mediaErr == nil {
|
||||
session.setCallMediaReady(call.callID)
|
||||
session.setCallState(call.callID, "early_media")
|
||||
}
|
||||
} else if response.StatusCode >= 180 {
|
||||
session.setCallState(call.callID, "ringing")
|
||||
}
|
||||
if reliableProvisional(response) {
|
||||
go session.sendPRACK(call, response)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
session.callMu.Lock()
|
||||
call.to = response.value("To")
|
||||
call.remoteTag = headerParameter(call.to, "tag")
|
||||
if contact := headerURI(response.value("Contact")); contact != "" {
|
||||
call.target = contact
|
||||
}
|
||||
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
||||
call.routes = reverseStrings(recordRoutes)
|
||||
}
|
||||
session.callMu.Unlock()
|
||||
session.updateCallDialogFromResponse(call, response)
|
||||
mediaErr := call.media.configureRemote(response.Body)
|
||||
_ = session.sendACK(call)
|
||||
if mediaErr != nil {
|
||||
@@ -197,13 +224,23 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
}
|
||||
session.setCallMediaReady(call.callID)
|
||||
session.setCallState(call.callID, "active")
|
||||
} else if session.callWasTerminated(call.callID) {
|
||||
// CANCEL normally causes the pending INVITE transaction to finish
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
|
||||
session.startSessionTimer(call, response.value("Session-Expires"))
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
|
||||
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"error", safeSIPDiagnostic(ackErr.Error()),
|
||||
)
|
||||
}
|
||||
if session.callWasTerminated(call.callID) {
|
||||
// CANCEL normally causes the pending INVITE transaction to finish
|
||||
// with 487 Request Terminated. It is the expected response to our
|
||||
// local hang-up, not a new network rejection.
|
||||
session.finishCall(call.callID, "ended", response.StatusCode, diagnostic)
|
||||
} else {
|
||||
session.finishCall(call.callID, "failed", response.StatusCode, diagnostic)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -223,7 +260,13 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
||||
}
|
||||
request, respond := call.invite, call.respond
|
||||
session.callMu.Unlock()
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, call.media.answerSDP(session.localMediaIP()))
|
||||
response, err := buildSIPResponseWithBody(
|
||||
request,
|
||||
200,
|
||||
session.fromTag,
|
||||
call.media.answerSDP(session.localMediaIP()),
|
||||
session.dialogContactHeader(),
|
||||
)
|
||||
if err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
@@ -231,6 +274,7 @@ func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, e
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
session.setCallState(id, "active")
|
||||
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||
if call.media.ready() {
|
||||
session.setCallMediaReady(id)
|
||||
}
|
||||
@@ -264,7 +308,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
method := "BYE"
|
||||
if direction == "outgoing" && (state == "dialing" || state == "ringing") {
|
||||
if direction == "outgoing" && (state == "dialing" || state == "ringing" || state == "early_media") {
|
||||
method = "CANCEL"
|
||||
}
|
||||
err := session.sendDialogRequest(ctx, call, method)
|
||||
@@ -288,6 +332,12 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
if callID == "" {
|
||||
return true
|
||||
}
|
||||
session.callMu.Lock()
|
||||
existing := session.calls[callID]
|
||||
session.callMu.Unlock()
|
||||
if existing != nil && existing.public.State == "active" {
|
||||
return session.handleDialogOffer(request, respond, existing)
|
||||
}
|
||||
number := identityNumber(request.value("From"))
|
||||
target := headerURI(request.value("Contact"))
|
||||
if target == "" {
|
||||
@@ -311,8 +361,9 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
}
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
||||
callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag,
|
||||
callID: callID, target: target, inviteTarget: request.URI, from: request.value("To") + ";tag=" + session.fromTag,
|
||||
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"), media: media,
|
||||
pracked: make(map[string]bool),
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
@@ -322,6 +373,21 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
_ = respond(response)
|
||||
}
|
||||
return true
|
||||
case "PRACK":
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
}
|
||||
return true
|
||||
case "UPDATE":
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
session.callMu.Lock()
|
||||
call := session.calls[callID]
|
||||
session.callMu.Unlock()
|
||||
if call == nil {
|
||||
return false
|
||||
}
|
||||
return session.handleDialogOffer(request, respond, call)
|
||||
case "ACK":
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
session.callMu.Lock()
|
||||
@@ -366,9 +432,199 @@ func (session *Session) sendACK(call *imsCall) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// sendRejectedInviteACK acknowledges a non-2xx final response using the
|
||||
// original INVITE transaction branch and request URI. Unlike a 2xx ACK this is
|
||||
// part of the INVITE transaction; sending a dialog-style ACK with a new branch
|
||||
// leaves the P-CSCF retransmitting the rejection and leaking transaction state.
|
||||
func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipResponse) error {
|
||||
if call == nil || response == nil || response.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
if session == nil || session.conn == nil {
|
||||
return errors.New("ims: SIP connection unavailable for rejected INVITE ACK")
|
||||
}
|
||||
target := call.inviteTarget
|
||||
if target == "" {
|
||||
target = call.target
|
||||
}
|
||||
to := strings.TrimSpace(response.value("To"))
|
||||
if to == "" {
|
||||
to = call.to
|
||||
}
|
||||
lines := []string{
|
||||
"ACK " + target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), call.branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
session.mu.Lock()
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
session.mu.Unlock()
|
||||
lines = append(lines, securityHeaders...)
|
||||
for _, route := range call.routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
lines = append(lines,
|
||||
"From: "+call.from,
|
||||
"To: "+to,
|
||||
"Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d ACK", call.cseq),
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"Content-Length: 0", "", "",
|
||||
)
|
||||
session.writeMu.Lock()
|
||||
_, err := session.conn.Write([]byte(strings.Join(lines, "\r\n")))
|
||||
session.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func reliableProvisional(response *sipResponse) bool {
|
||||
if response == nil || strings.TrimSpace(response.value("RSeq")) == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range strings.Split(strings.ToLower(response.value("Require")), ",") {
|
||||
if strings.TrimSpace(token) == "100rel" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (session *Session) updateCallDialogFromResponse(call *imsCall, response *sipResponse) {
|
||||
if call == nil || response == nil {
|
||||
return
|
||||
}
|
||||
session.callMu.Lock()
|
||||
defer session.callMu.Unlock()
|
||||
call.to = response.value("To")
|
||||
call.remoteTag = headerParameter(call.to, "tag")
|
||||
if contact := headerURI(response.value("Contact")); contact != "" {
|
||||
call.target = contact
|
||||
}
|
||||
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
||||
call.routes = reverseStrings(recordRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
|
||||
rseq := strings.TrimSpace(response.value("RSeq"))
|
||||
inviteCSeq := strings.TrimSpace(response.value("CSeq"))
|
||||
if rseq == "" || inviteCSeq == "" {
|
||||
return
|
||||
}
|
||||
key := rseq + "|" + inviteCSeq
|
||||
session.callMu.Lock()
|
||||
if call.pracked == nil {
|
||||
call.pracked = make(map[string]bool)
|
||||
}
|
||||
if call.pracked[key] || call.public.EndedAt != nil {
|
||||
session.callMu.Unlock()
|
||||
return
|
||||
}
|
||||
call.pracked[key] = true
|
||||
target, to, from := call.target, call.to, call.from
|
||||
routes := append([]string(nil), call.routes...)
|
||||
session.callMu.Unlock()
|
||||
|
||||
session.mu.Lock()
|
||||
cseq := session.cseq
|
||||
session.cseq++
|
||||
session.mu.Unlock()
|
||||
branch, _ := randomHex(12)
|
||||
lines := []string{
|
||||
"PRACK " + target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
session.mu.Lock()
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
session.mu.Unlock()
|
||||
lines = append(lines, securityHeaders...)
|
||||
for _, route := range routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
lines = append(lines,
|
||||
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
|
||||
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
"Content-Length: 0", "", "",
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
|
||||
defer cancel()
|
||||
result, err := session.exchangeRuntime(ctx, []byte(strings.Join(lines, "\r\n")), sipTransactionKey{callID: call.callID, cseq: cseq, method: "PRACK"})
|
||||
if err != nil || result.StatusCode < 200 || result.StatusCode >= 300 {
|
||||
reason := "reliable provisional response could not be acknowledged"
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
}
|
||||
session.finishCall(call.callID, "failed", 0, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) handleDialogOffer(request *sipRequest, respond func([]byte) error, call *imsCall) bool {
|
||||
var body []byte
|
||||
if len(request.Body) > 0 {
|
||||
if err := call.media.configureRemote(request.Body); err != nil {
|
||||
if response, buildErr := buildSIPResponseWithBody(request, 488, session.fromTag, nil); buildErr == nil {
|
||||
_ = respond(response)
|
||||
}
|
||||
return true
|
||||
}
|
||||
body = call.media.answerSDP(session.localMediaIP())
|
||||
session.setCallMediaReady(call.callID)
|
||||
}
|
||||
extraHeaders := []string(nil)
|
||||
if request.Method == "INVITE" {
|
||||
extraHeaders = append(extraHeaders, session.dialogContactHeader())
|
||||
}
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, body, extraHeaders...)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
session.startSessionTimer(call, request.value("Session-Expires"))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (session *Session) startSessionTimer(call *imsCall, header string) {
|
||||
value := strings.TrimSpace(strings.Split(header, ";")[0])
|
||||
seconds, err := strconv.Atoi(value)
|
||||
if err != nil || seconds < 90 || seconds > 86400 || call == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(session.refreshContext)
|
||||
session.callMu.Lock()
|
||||
if call.sessionCancel != nil {
|
||||
call.sessionCancel()
|
||||
}
|
||||
call.sessionExpires = seconds
|
||||
call.sessionCancel = cancel
|
||||
session.callMu.Unlock()
|
||||
go func() {
|
||||
interval := time.Duration(seconds) * time.Second / 2
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
refreshContext, cancelRefresh := context.WithTimeout(ctx, 15*time.Second)
|
||||
refreshErr := session.sendDialogRequest(refreshContext, call, "UPDATE")
|
||||
cancelRefresh()
|
||||
if refreshErr != nil {
|
||||
session.finishCall(call.callID, "failed", 0, "SIP session refresh failed")
|
||||
return
|
||||
}
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error {
|
||||
cseq := call.cseq
|
||||
if method == "BYE" {
|
||||
if method == "BYE" || method == "UPDATE" {
|
||||
session.mu.Lock()
|
||||
cseq = session.cseq
|
||||
session.cseq++
|
||||
@@ -396,15 +652,23 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
||||
if method == "CANCEL" {
|
||||
branch = call.branch
|
||||
}
|
||||
target := call.target
|
||||
if method == "CANCEL" && call.inviteTarget != "" {
|
||||
target = call.inviteTarget
|
||||
}
|
||||
to := call.to
|
||||
if to == "" {
|
||||
to = "<" + call.target + ">"
|
||||
}
|
||||
lines := []string{
|
||||
method + " " + call.target + " SIP/2.0",
|
||||
method + " " + target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
session.mu.Lock()
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
session.mu.Unlock()
|
||||
lines = append(lines, securityHeaders...)
|
||||
for _, route := range call.routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
@@ -413,8 +677,19 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
|
||||
"To: "+to,
|
||||
"Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
||||
"Content-Length: 0", "", "",
|
||||
"Supported: 100rel, timer",
|
||||
"User-Agent: "+session.callUserAgent(),
|
||||
)
|
||||
if method != "CANCEL" {
|
||||
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
|
||||
}
|
||||
if method == "UPDATE" {
|
||||
lines = append(lines, session.dialogContactHeader())
|
||||
}
|
||||
lines = append(lines, "Content-Length: 0", "", "")
|
||||
if method == "UPDATE" && call.sessionExpires > 0 {
|
||||
lines = append(lines[:len(lines)-3], fmt.Sprintf("Session-Expires: %d;refresher=uac", call.sessionExpires), "Content-Length: 0", "", "")
|
||||
}
|
||||
return []byte(strings.Join(lines, "\r\n"))
|
||||
}
|
||||
|
||||
@@ -426,7 +701,7 @@ func (session *Session) localMediaIP() net.IP {
|
||||
return addressIP(localAddress)
|
||||
}
|
||||
|
||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) {
|
||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte, extraHeaders ...string) ([]byte, error) {
|
||||
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated", 488: "Not Acceptable Here"}
|
||||
reason := reasons[status]
|
||||
if reason == "" {
|
||||
@@ -446,6 +721,14 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
||||
lines = append(lines, "Via: "+value)
|
||||
}
|
||||
lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq)
|
||||
for _, header := range extraHeaders {
|
||||
if strings.TrimSpace(header) != "" {
|
||||
lines = append(lines, header)
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(request.value("Session-Expires")); value != "" && status >= 200 && status < 300 && (request.Method == "INVITE" || request.Method == "UPDATE") {
|
||||
lines = append(lines, "Supported: timer", "Session-Expires: "+value)
|
||||
}
|
||||
if len(body) > 0 {
|
||||
lines = append(lines, "Content-Type: application/sdp")
|
||||
}
|
||||
@@ -453,10 +736,110 @@ func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body
|
||||
return append([]byte(strings.Join(lines, "\r\n")), body...), nil
|
||||
}
|
||||
|
||||
func (session *Session) dialogContactHeader() string {
|
||||
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
|
||||
return ""
|
||||
}
|
||||
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
|
||||
if strings.TrimSpace(session.instanceID) != "" {
|
||||
contact += `;+sip.instance="<` + session.instanceID + `>"`
|
||||
}
|
||||
return contact + `;audio;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
|
||||
}
|
||||
|
||||
func callTargetURI(number, domain string, profile vowifi.CarrierProfile) string {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if profile.IMSDialURIScheme == "sip" {
|
||||
target := "sip:" + number + "@" + domain
|
||||
if profile.IMSUserEqPhone {
|
||||
target += ";user=phone"
|
||||
}
|
||||
return target
|
||||
}
|
||||
if strings.HasPrefix(number, "+") {
|
||||
return "tel:" + number
|
||||
}
|
||||
return "tel:" + number + ";phone-context=" + domain
|
||||
}
|
||||
|
||||
// callOriginatingIdentitiesLocked selects only a number that IMS explicitly
|
||||
// associated with this registration. 3GPP originating sessions use that
|
||||
// public identity in both From and P-Preferred-Identity; some TAS deployments
|
||||
// accept an IMSI IMPU at the P-CSCF and then terminate the session immediately.
|
||||
// The fallback deliberately remains the registered IMPU and never derives a
|
||||
// telephone number from IMSI digits.
|
||||
func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierProfile) (from, preferred, source string) {
|
||||
if number, numberSource, ok := vowifi.ExtractAssociatedMSISDN(session.evidence); ok {
|
||||
from = "sip:" + number + "@" + session.identity.domain
|
||||
if profile.IMSUserEqPhone {
|
||||
from += ";user=phone"
|
||||
}
|
||||
return from, "tel:" + number, numberSource
|
||||
}
|
||||
return session.identity.public, session.identity.public, "registered_impu"
|
||||
}
|
||||
|
||||
func (session *Session) pAccessNetworkInfo() string {
|
||||
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
||||
node := strings.TrimSpace(profile.PANINode)
|
||||
if node == "" {
|
||||
node = "000000000000"
|
||||
}
|
||||
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||
value += ";country=" + country
|
||||
}
|
||||
return value + ";network-provided"
|
||||
}
|
||||
|
||||
func (session *Session) callUserAgent() string {
|
||||
if session != nil && session.provider != nil {
|
||||
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "vocat/1"
|
||||
}
|
||||
|
||||
func callResponseDiagnostic(response *sipResponse) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if reason := safeSIPDiagnostic(response.Reason); reason != "" {
|
||||
parts = append(parts, reason)
|
||||
}
|
||||
for _, name := range []string{"Reason", "Warning"} {
|
||||
for _, value := range response.values(name) {
|
||||
if value = safeSIPDiagnostic(value); value != "" {
|
||||
parts = append(parts, name+": "+value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return safeSIPDiagnostic(strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func (session *Session) logCallResponse(response *sipResponse, diagnostic string) {
|
||||
if session == nil || session.provider == nil || session.provider.config.Logger == nil || response == nil {
|
||||
return
|
||||
}
|
||||
session.provider.config.Logger.Info("IMS call response",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"diagnostic", diagnostic,
|
||||
"content_type", safeSIPDiagnostic(response.value("Content-Type")),
|
||||
"body_bytes", len(response.Body),
|
||||
)
|
||||
}
|
||||
|
||||
func (session *Session) setCallState(id, state string) {
|
||||
session.callMu.Lock()
|
||||
if call := session.calls[id]; call != nil {
|
||||
call.public.State = state
|
||||
if state == "active" && call.public.AnsweredAt == nil {
|
||||
now := time.Now().UTC()
|
||||
call.public.AnsweredAt = &now
|
||||
}
|
||||
if state != "ended" && state != "failed" {
|
||||
call.public.EndedAt = nil
|
||||
}
|
||||
@@ -509,6 +892,10 @@ func (session *Session) finishCall(id, state string, code int, reason string) {
|
||||
call.public.Reason = reason
|
||||
}
|
||||
call.public.EndedAt = &now
|
||||
if call.sessionCancel != nil {
|
||||
call.sessionCancel()
|
||||
call.sessionCancel = nil
|
||||
}
|
||||
}
|
||||
session.callMu.Unlock()
|
||||
if media != nil {
|
||||
|
||||
@@ -2,6 +2,8 @@ package ims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,7 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
||||
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
||||
client, peer := net.Pipe()
|
||||
defer client.Close()
|
||||
defer peer.Close()
|
||||
session := &Session{
|
||||
fromTag: "local-tag", calls: make(map[string]*imsCall), conn: client,
|
||||
identity: identitySet{user: "subscriber"}, transport: "udp",
|
||||
}
|
||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||
"INVITE sip:[email protected] SIP/2.0",
|
||||
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming",
|
||||
@@ -42,6 +50,9 @@ func TestIncomingCallCanRingAndAnswerWithMediaOffer(t *testing.T) {
|
||||
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=sendrecv") {
|
||||
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
|
||||
}
|
||||
if !strings.Contains(string(responses[1]), "Contact: <sip:subscriber@pipe;transport=udp>") {
|
||||
t.Fatalf("answer omitted dialog Contact: %q", responses[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingCallCanBeRejected(t *testing.T) {
|
||||
@@ -116,3 +127,155 @@ func TestValidCallNumber(t *testing.T) {
|
||||
t.Fatal("call number validation mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
||||
client, peer := net.Pipe()
|
||||
defer peer.Close()
|
||||
refreshContext, cancelRefresh := context.WithCancel(context.Background())
|
||||
defer cancelRefresh()
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
UserAgent: "VoCat Test",
|
||||
SecurityMode: SecurityDisabled,
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
|
||||
HomeMCC: "234", HomeMNC: "33", IMSI: "234330000000001", ICCID: "8944300000000000000",
|
||||
}},
|
||||
identity: identitySet{
|
||||
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||
public: "sip:[email protected]",
|
||||
user: "234330000000001",
|
||||
},
|
||||
endpoint: pcscfEndpoint{host: "pcscf.test", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
fromTag: "local-tag",
|
||||
instanceID: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||
cseq: 1,
|
||||
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||
calls: make(map[string]*imsCall),
|
||||
refreshContext: refreshContext,
|
||||
evidence: vowifi.IMSEvidence{
|
||||
PAssociatedURI: []string{"<tel:+447700900123>"},
|
||||
ServiceRoute: []string{"<sip:route.ims.test;lr>"},
|
||||
},
|
||||
}
|
||||
wireResult := make(chan string, 1)
|
||||
go func() {
|
||||
packet, _ := io.ReadAll(peer)
|
||||
wireResult <- string(packet)
|
||||
}()
|
||||
|
||||
call, err := session.DialCall(context.Background(), "888")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.finishCall(call.ID, "ended", 0, "test complete")
|
||||
cancelRefresh()
|
||||
_ = client.Close()
|
||||
wire := <-wireResult
|
||||
|
||||
for _, expected := range []string{
|
||||
"INVITE sip:[email protected] SIP/2.0\r\n",
|
||||
"To: <sip:[email protected]>\r\n",
|
||||
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
||||
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;country=GB;network-provided\r\n",
|
||||
"User-Agent: VoCat Test\r\n",
|
||||
"Accept: application/sdp\r\n",
|
||||
} {
|
||||
if !strings.Contains(wire, expected) {
|
||||
t.Fatalf("INVITE omitted %q:\n%s", expected, wire)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
|
||||
session := &Session{
|
||||
identity: identitySet{
|
||||
domain: "ims.mnc033.mcc234.3gppnetwork.org",
|
||||
public: "sip:[email protected]",
|
||||
},
|
||||
}
|
||||
from, preferred, source := session.callOriginatingIdentitiesLocked(vowifi.CarrierProfile{})
|
||||
if from != session.identity.public || preferred != session.identity.public || source != "registered_impu" {
|
||||
t.Fatalf("fallback identities = (%q, %q, %q)", from, preferred, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallTargetURIUsesPhoneContextOnlyForLocalNumbers(t *testing.T) {
|
||||
domain := "ims.mnc033.mcc234.3gppnetwork.org"
|
||||
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:888;phone-context="+domain {
|
||||
t.Fatalf("local target = %q", got)
|
||||
}
|
||||
if got := callTargetURI("+447700900123", domain, vowifi.CarrierProfile{IMSDialURIScheme: "tel"}); got != "tel:+447700900123" {
|
||||
t.Fatalf("global target = %q", got)
|
||||
}
|
||||
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip"}); got != "sip:888@"+domain {
|
||||
t.Fatalf("SIP target = %q", got)
|
||||
}
|
||||
if got := callTargetURI("888", domain, vowifi.CarrierProfile{IMSDialURIScheme: "sip", IMSUserEqPhone: true}); got != "sip:888@"+domain+";user=phone" {
|
||||
t.Fatalf("SIP user=phone target = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallResponseDiagnosticIncludesNetworkReason(t *testing.T) {
|
||||
response := &sipResponse{
|
||||
StatusCode: 487,
|
||||
Reason: "Request Terminated",
|
||||
Headers: map[string][]string{
|
||||
"reason": {`Q.850;cause=31;text="Normal, unspecified"`},
|
||||
},
|
||||
}
|
||||
want := `Request Terminated; Reason: Q.850;cause=31;text="Normal, unspecified"`
|
||||
if got := callResponseDiagnostic(response); got != want {
|
||||
t.Fatalf("diagnostic = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedInviteACKUsesOriginalTransaction(t *testing.T) {
|
||||
client, peer := net.Pipe()
|
||||
defer client.Close()
|
||||
defer peer.Close()
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{UserAgent: "VoCat Test"}},
|
||||
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "33"}},
|
||||
identity: identitySet{domain: "ims.mnc033.mcc234.3gppnetwork.org"},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
}
|
||||
call := &imsCall{
|
||||
callID: "call-1",
|
||||
inviteTarget: "sip:[email protected]",
|
||||
from: "<sip:[email protected]>;tag=local",
|
||||
to: "<sip:[email protected]>",
|
||||
branch: "original-branch",
|
||||
cseq: 41,
|
||||
routes: []string{"<sip:pcscf.test;lr>"},
|
||||
}
|
||||
response := &sipResponse{StatusCode: 487, Headers: map[string][]string{
|
||||
"to": {"<sip:[email protected]>;tag=tas"},
|
||||
}}
|
||||
ackResult := make(chan string, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 4096)
|
||||
count, _ := peer.Read(buffer)
|
||||
ackResult <- string(buffer[:count])
|
||||
}()
|
||||
if err := session.sendRejectedInviteACK(call, response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ack := <-ackResult
|
||||
for _, expected := range []string{
|
||||
"ACK sip:[email protected] SIP/2.0\r\n",
|
||||
"branch=z9hG4bKoriginal-branch;rport",
|
||||
"To: <sip:[email protected]>;tag=tas\r\n",
|
||||
"CSeq: 41 ACK\r\n",
|
||||
} {
|
||||
if !strings.Contains(ack, expected) {
|
||||
t.Fatalf("rejected INVITE ACK omitted %q:\n%s", expected, ack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+178
-54
@@ -36,20 +36,24 @@ var (
|
||||
// LocalAddress is empty, Provider uses the corresponding value proven by the
|
||||
// TunnelSession. The default transport is TCP and the default port is 5060.
|
||||
type Config struct {
|
||||
PCSCF string
|
||||
LocalAddress string
|
||||
Transport string
|
||||
TransportByPLMN map[string]string
|
||||
Port int
|
||||
RegistrationExpiry time.Duration
|
||||
TransactionTimeout time.Duration
|
||||
PrivateIdentity string
|
||||
PublicIdentity string
|
||||
UserAgent string
|
||||
SecurityMode SecurityMode
|
||||
IPSecInstaller IPSecSAInstaller
|
||||
ProtectedClientPort int
|
||||
ProtectedServerPort int
|
||||
PCSCF string
|
||||
LocalAddress string
|
||||
Transport string
|
||||
TransportByPLMN map[string]string
|
||||
// AutoTransportFallback tries the alternate TCP/UDP transport only when
|
||||
// the initial P-CSCF attempt produced no SIP response at all. A challenge
|
||||
// or rejection is authoritative and is never retried as another transport.
|
||||
AutoTransportFallback bool
|
||||
Port int
|
||||
RegistrationExpiry time.Duration
|
||||
TransactionTimeout time.Duration
|
||||
PrivateIdentity string
|
||||
PublicIdentity string
|
||||
UserAgent string
|
||||
SecurityMode SecurityMode
|
||||
IPSecInstaller IPSecSAInstaller
|
||||
ProtectedClientPort int
|
||||
ProtectedServerPort int
|
||||
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
|
||||
// and AT+CSCA empty. It must be an international or national digit string.
|
||||
SMSCenter string
|
||||
@@ -71,9 +75,11 @@ type Config struct {
|
||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||
// runtime dependency outside the Go standard library.
|
||||
type Provider struct {
|
||||
aka vowifi.AKAProvider
|
||||
config Config
|
||||
installer IPSecSAInstaller
|
||||
aka vowifi.AKAProvider
|
||||
config Config
|
||||
installer IPSecSAInstaller
|
||||
transportMu sync.RWMutex
|
||||
transportCache map[string]string
|
||||
}
|
||||
|
||||
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||
@@ -88,7 +94,10 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||
if installer == nil {
|
||||
installer = defaultIPSecInstaller()
|
||||
}
|
||||
return &Provider{aka: aka, config: normalized, installer: installer}, nil
|
||||
return &Provider{
|
||||
aka: aka, config: normalized, installer: installer,
|
||||
transportCache: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeConfig(config Config) (Config, error) {
|
||||
@@ -224,10 +233,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
||||
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||
}
|
||||
transport := transportForIdentity(provider.config, request.Identity)
|
||||
if transport == "" {
|
||||
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
|
||||
if cached := provider.cachedTransport(request.Identity); cached != "" {
|
||||
transport = cached
|
||||
carrierSelected = true
|
||||
}
|
||||
if transport == "" && !carrierSelected {
|
||||
transport = transportHint
|
||||
}
|
||||
if transport == "" {
|
||||
transport = provider.config.Transport
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "tcp"
|
||||
}
|
||||
@@ -250,31 +266,96 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
||||
}
|
||||
|
||||
connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err)
|
||||
transports := []string{transport}
|
||||
if provider.config.AutoTransportFallback {
|
||||
alternate := "udp"
|
||||
if transport == "udp" {
|
||||
alternate = "tcp"
|
||||
}
|
||||
transports = append(transports, alternate)
|
||||
}
|
||||
session, err := newSession(provider, request, identities, endpoint, transport, connection)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := session.establish(ctx); err != nil {
|
||||
var lastErr error
|
||||
for attempt, candidate := range transports {
|
||||
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||
if dialErr != nil {
|
||||
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
||||
if attempt+1 < len(transports) && ctx.Err() == nil {
|
||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||
if sessionErr != nil {
|
||||
_ = connection.Close()
|
||||
return nil, sessionErr
|
||||
}
|
||||
establishErr := session.establish(ctx)
|
||||
if establishErr == nil {
|
||||
provider.rememberTransport(request.Identity, candidate)
|
||||
if attempt > 0 {
|
||||
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
||||
"transport", candidate)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
sipResponseObserved := session.evidence.LastSIPCode != 0
|
||||
session.abort()
|
||||
return nil, err
|
||||
lastErr = establishErr
|
||||
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||
}
|
||||
return session, nil
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||
if transport, selected := carrierTransportForIdentity(config, identity); selected {
|
||||
return transport
|
||||
}
|
||||
return config.Transport
|
||||
}
|
||||
|
||||
func carrierTransportForIdentity(config Config, identity vowifi.SIMIdentity) (string, bool) {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||
return transport, true
|
||||
}
|
||||
if transport := vowifi.ResolveCarrierProfile(identity).IMSTransport; transport != "" {
|
||||
return transport, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func transportCacheKey(identity vowifi.SIMIdentity) string {
|
||||
if iccid := strings.TrimSpace(identity.ICCID); iccid != "" {
|
||||
return "iccid:" + iccid
|
||||
}
|
||||
return "plmn:" + strings.TrimSpace(identity.HomeMCC) + "/" + strings.TrimSpace(identity.HomeMNC)
|
||||
}
|
||||
|
||||
func (provider *Provider) cachedTransport(identity vowifi.SIMIdentity) string {
|
||||
provider.transportMu.RLock()
|
||||
transport := provider.transportCache[transportCacheKey(identity)]
|
||||
provider.transportMu.RUnlock()
|
||||
return transport
|
||||
}
|
||||
|
||||
func (provider *Provider) rememberTransport(identity vowifi.SIMIdentity, transport string) {
|
||||
provider.transportMu.Lock()
|
||||
provider.transportCache[transportCacheKey(identity)] = transport
|
||||
provider.transportMu.Unlock()
|
||||
}
|
||||
|
||||
func (provider *Provider) logTransportFallback(identity vowifi.SIMIdentity, from, to string, err error) {
|
||||
provider.config.Logger.Warn("IMS P-CSCF did not respond; trying alternate SIP transport",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(identity).ID,
|
||||
"from_transport", from, "to_transport", to, "error", err)
|
||||
}
|
||||
|
||||
type identitySet struct {
|
||||
domain string
|
||||
private string
|
||||
@@ -298,7 +379,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||
privateDomain := domain
|
||||
publicDomain := domain
|
||||
if vowifi.IsATT310280(identity) {
|
||||
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
|
||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||
// the generic 3GPP PLMN IMS domain.
|
||||
domain = "one.att.net"
|
||||
@@ -620,18 +701,11 @@ func newSession(
|
||||
}
|
||||
|
||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||
if usesO2GermanyIMSProfile(identity) {
|
||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
||||
}
|
||||
|
||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
return mcc+mnc == "2623"
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
|
||||
}
|
||||
|
||||
func (session *Session) abort() {
|
||||
@@ -872,7 +946,7 @@ func (session *Session) buildRegister(
|
||||
}
|
||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||
supported := "path, gruu"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, SUBSCRIBE, NOTIFY"
|
||||
if o2Germany {
|
||||
// Match the complete IMS capability set used by the previously working
|
||||
// VoHive client. O2 validates more of the initial UE security profile
|
||||
@@ -913,6 +987,12 @@ func (session *Session) buildRegister(
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
`Accept-Contact: *;+g.3gpp.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() {
|
||||
lines = append(lines,
|
||||
@@ -951,19 +1031,33 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
method: "REGISTER",
|
||||
})
|
||||
}
|
||||
deadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
transactionDeadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(transactionDeadline) {
|
||||
transactionDeadline = contextDeadline
|
||||
}
|
||||
readUDP := session.protectedUDP
|
||||
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
|
||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||
return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||
}
|
||||
if protectedUDP {
|
||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||
return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||
setReadDeadline := func(deadline time.Time) error {
|
||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||
}
|
||||
if protectedUDP {
|
||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
retransmitInterval := time.Duration(0)
|
||||
readDeadline := transactionDeadline
|
||||
if session.transport == "udp" {
|
||||
retransmitInterval = sipMessageRetransmitT1
|
||||
if candidate := time.Now().Add(retransmitInterval); candidate.Before(readDeadline) {
|
||||
readDeadline = candidate
|
||||
}
|
||||
}
|
||||
if err := setReadDeadline(readDeadline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stopCancellation := context.AfterFunc(ctx, func() {
|
||||
_ = session.conn.SetDeadline(time.Now())
|
||||
@@ -976,6 +1070,7 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
|
||||
}
|
||||
|
||||
retransmissions := 0
|
||||
for {
|
||||
var response *sipResponse
|
||||
var err error
|
||||
@@ -1004,6 +1099,31 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
if contextErr := ctx.Err(); contextErr != nil {
|
||||
return nil, contextErr
|
||||
}
|
||||
var networkErr net.Error
|
||||
if retransmitInterval > 0 && errors.As(err, &networkErr) && networkErr.Timeout() &&
|
||||
time.Now().Before(transactionDeadline) {
|
||||
retransmitInterval *= 2
|
||||
if retransmitInterval > sipMessageRetransmitMax {
|
||||
retransmitInterval = sipMessageRetransmitMax
|
||||
}
|
||||
nextDeadline := time.Now().Add(retransmitInterval)
|
||||
if nextDeadline.After(transactionDeadline) {
|
||||
nextDeadline = transactionDeadline
|
||||
}
|
||||
// The previous read deadline has already expired and net.Conn applies
|
||||
// it to writes too. Extend it before retransmitting.
|
||||
if deadlineErr := setReadDeadline(nextDeadline); deadlineErr != nil {
|
||||
return nil, deadlineErr
|
||||
}
|
||||
if _, writeErr := session.conn.Write(request); writeErr != nil {
|
||||
return nil, fmt.Errorf("ims: retransmit SIP REGISTER: %w", writeErr)
|
||||
}
|
||||
retransmissions++
|
||||
session.provider.config.Logger.Debug("IMS SIP REGISTER retransmitted",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"transport", session.transport, "attempt", retransmissions)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
|
||||
@@ -1014,6 +1134,10 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
continue
|
||||
}
|
||||
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
||||
retransmitInterval = 0
|
||||
if err := setReadDeadline(transactionDeadline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return response, nil
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,6 +20,40 @@ type evidenceTunnel struct {
|
||||
evidence vowifi.TunnelEvidence
|
||||
}
|
||||
|
||||
type immediateTimeoutError struct{}
|
||||
|
||||
func (immediateTimeoutError) Error() string { return "test timeout" }
|
||||
func (immediateTimeoutError) Timeout() bool { return true }
|
||||
func (immediateTimeoutError) Temporary() bool { return true }
|
||||
|
||||
type registerRetransmitConn struct {
|
||||
writes int
|
||||
response []byte
|
||||
}
|
||||
|
||||
func (connection *registerRetransmitConn) Read(destination []byte) (int, error) {
|
||||
if connection.writes < 2 {
|
||||
return 0, immediateTimeoutError{}
|
||||
}
|
||||
return copy(destination, connection.response), nil
|
||||
}
|
||||
|
||||
func (connection *registerRetransmitConn) Write(source []byte) (int, error) {
|
||||
connection.writes++
|
||||
return len(source), nil
|
||||
}
|
||||
|
||||
func (*registerRetransmitConn) Close() error { return nil }
|
||||
func (*registerRetransmitConn) LocalAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
|
||||
}
|
||||
func (*registerRetransmitConn) RemoteAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
|
||||
}
|
||||
func (*registerRetransmitConn) SetDeadline(time.Time) error { return nil }
|
||||
func (*registerRetransmitConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (*registerRetransmitConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
|
||||
return tunnel.evidence
|
||||
}
|
||||
@@ -73,6 +109,62 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
||||
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
||||
}
|
||||
if got := transportForIdentity(Config{
|
||||
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
||||
}, identity); got != "tcp" {
|
||||
t.Fatalf("explicit configuration did not override profile: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCachesSuccessfulTransportPerSIM(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := &Provider{transportCache: make(map[string]string)}
|
||||
first := vowifi.SIMIdentity{ICCID: "8901000000000000001", HomeMCC: "001", HomeMNC: "01"}
|
||||
second := vowifi.SIMIdentity{ICCID: "8901000000000000002", HomeMCC: "001", HomeMNC: "01"}
|
||||
provider.rememberTransport(first, "udp")
|
||||
if got := provider.cachedTransport(first); got != "udp" {
|
||||
t.Fatalf("cached first transport = %q", got)
|
||||
}
|
||||
if got := provider.cachedTransport(second); got != "" {
|
||||
t.Fatalf("second SIM inherited cached transport %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPRegisterRetransmitsBeforeTransactionTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
connection := ®isterRetransmitConn{response: []byte(strings.Join([]string{
|
||||
"SIP/2.0 200 OK",
|
||||
"Call-ID: register-retransmit-test",
|
||||
"CSeq: 7 REGISTER",
|
||||
"Content-Length: 0",
|
||||
"",
|
||||
"",
|
||||
}, "\r\n"))}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
TransactionTimeout: 3 * time.Second,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"}},
|
||||
transport: "udp",
|
||||
conn: connection,
|
||||
callID: "register-retransmit-test",
|
||||
}
|
||||
response, err := session.exchange(context.Background(), []byte("REGISTER test"), 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != 200 || connection.writes != 2 {
|
||||
t.Fatalf("response=%#v writes=%d, want SIP 200 after one retransmission", response, connection.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
|
||||
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
|
||||
" 23410 ": " +447802000332 ",
|
||||
@@ -283,7 +375,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
return fmt.Errorf("unexpected start line %q", startLine)
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"p-access-network-info",
|
||||
"p-visited-network-id",
|
||||
"p-preferred-identity",
|
||||
} {
|
||||
@@ -295,6 +386,13 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
)
|
||||
}
|
||||
}
|
||||
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
|
||||
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
|
||||
}
|
||||
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
||||
}
|
||||
if step == 0 {
|
||||
if headers["authorization"] != "" {
|
||||
return errors.New("initial REGISTER unexpectedly authenticated")
|
||||
|
||||
@@ -534,25 +534,7 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
||||
for _, protocol := range flow.protocols {
|
||||
operations = append(operations, xfrmOperation{
|
||||
description: flow.description + " " + protocol + " policy",
|
||||
arguments: []string{
|
||||
flow.family,
|
||||
"xfrm", "policy", "add",
|
||||
"src", flow.sourcePrefix,
|
||||
"dst", flow.destinationPrefix,
|
||||
"proto", protocol,
|
||||
"sport", strconv.Itoa(flow.sourcePort),
|
||||
"dport", strconv.Itoa(flow.destinationPort),
|
||||
"dir", flow.direction,
|
||||
"priority", "100",
|
||||
"tmpl",
|
||||
"src", flow.templateSource.String(),
|
||||
"dst", flow.templateDestination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
||||
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
||||
"mode", "transport",
|
||||
"level", "required",
|
||||
},
|
||||
arguments: xfrmPolicyArgs(flow, protocol, false),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -568,16 +550,7 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
||||
protocol := flow.protocols[protocolIndex]
|
||||
operations = append(operations, xfrmOperation{
|
||||
description: "delete " + flow.description + " " + protocol + " policy",
|
||||
arguments: []string{
|
||||
flow.family,
|
||||
"xfrm", "policy", "delete",
|
||||
"src", flow.sourcePrefix,
|
||||
"dst", flow.destinationPrefix,
|
||||
"proto", protocol,
|
||||
"sport", strconv.Itoa(flow.sourcePort),
|
||||
"dport", strconv.Itoa(flow.destinationPort),
|
||||
"dir", flow.direction,
|
||||
},
|
||||
arguments: xfrmPolicyArgs(flow, protocol, true),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -606,6 +579,44 @@ func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
|
||||
return operations
|
||||
}
|
||||
|
||||
func xfrmPolicyArgs(flow xfrmFlow, protocol string, delete bool) []string {
|
||||
args := []string{
|
||||
flow.family,
|
||||
"xfrm", "policy",
|
||||
}
|
||||
if delete {
|
||||
args = append(args, "delete")
|
||||
} else {
|
||||
args = append(args, "add")
|
||||
}
|
||||
args = append(args,
|
||||
"src", flow.sourcePrefix,
|
||||
"dst", flow.destinationPrefix,
|
||||
"proto", protocol,
|
||||
)
|
||||
if flow.sourcePort > 0 {
|
||||
args = append(args, "sport", strconv.Itoa(flow.sourcePort))
|
||||
}
|
||||
if flow.destinationPort > 0 {
|
||||
args = append(args, "dport", strconv.Itoa(flow.destinationPort))
|
||||
}
|
||||
args = append(args, "dir", flow.direction)
|
||||
if delete {
|
||||
return args
|
||||
}
|
||||
return append(args,
|
||||
"priority", "100",
|
||||
"tmpl",
|
||||
"src", flow.templateSource.String(),
|
||||
"dst", flow.templateDestination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", flow.spi),
|
||||
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
|
||||
"mode", "transport",
|
||||
"level", "required",
|
||||
)
|
||||
}
|
||||
|
||||
type xfrmFlow struct {
|
||||
description string
|
||||
family string
|
||||
@@ -650,7 +661,7 @@ func xfrmFlows(config IPSecSAConfig) []xfrmFlow {
|
||||
{
|
||||
description: "P-CSCF-client to UE-server", family: family,
|
||||
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
|
||||
sourcePort: config.PCSCFClientPort, destinationPort: config.UEServerPort,
|
||||
sourcePort: 0, destinationPort: config.UEServerPort,
|
||||
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
|
||||
spi: config.UEServerSPI, reqid: serverPairReqID(config),
|
||||
protocols: []string{"tcp", "udp"},
|
||||
@@ -772,7 +783,7 @@ func zeroBytes(value []byte) {
|
||||
}
|
||||
|
||||
func (session *Session) securityOffered() bool {
|
||||
return session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
||||
return session.provider != nil && session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
|
||||
}
|
||||
|
||||
func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) {
|
||||
@@ -916,9 +927,7 @@ func (session *Session) validProtectedUDPSource(remote *net.UDPAddr) bool {
|
||||
return false
|
||||
}
|
||||
expectedIP := addressIP(session.conn.RemoteAddr())
|
||||
return expectedIP != nil &&
|
||||
expectedIP.Equal(remote.IP) &&
|
||||
remote.Port == session.securityAgreement.selected.portClient
|
||||
return expectedIP != nil && expectedIP.Equal(remote.IP)
|
||||
}
|
||||
|
||||
func (session *Session) effectiveSecurityMode() string {
|
||||
|
||||
@@ -531,8 +531,8 @@ func serveProtectedRegistrar(
|
||||
return result, fmt.Errorf("protected Contact = %q", headers["contact"])
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(startLine), "MESSAGE") ||
|
||||
strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
||||
return result, errors.New("registration transaction advertised or sent MESSAGE")
|
||||
!strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") {
|
||||
return result, errors.New("registration transaction did not advertise MESSAGE correctly")
|
||||
}
|
||||
|
||||
if _, err := protectedConnection.Write(testResponse(
|
||||
|
||||
@@ -287,14 +287,18 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
"tcp 40666 50600 out": false,
|
||||
"udp 40666 50600 out": false,
|
||||
"tcp 50600 40666 in": false,
|
||||
"tcp 50601 55610 in": false,
|
||||
"udp 50601 55610 in": false,
|
||||
"tcp * 55610 in": false,
|
||||
"udp * 55610 in": false,
|
||||
"tcp 55610 50601 out": false,
|
||||
}
|
||||
for _, operation := range install[4:] {
|
||||
sourcePort := "*"
|
||||
if value, ok := optionalArgumentAfter(operation.arguments, "sport"); ok {
|
||||
sourcePort = value
|
||||
}
|
||||
key := strings.Join([]string{
|
||||
argumentAfter(t, operation.arguments, "proto"),
|
||||
argumentAfter(t, operation.arguments, "sport"),
|
||||
sourcePort,
|
||||
argumentAfter(t, operation.arguments, "dport"),
|
||||
argumentAfter(t, operation.arguments, "dir"),
|
||||
}, " ")
|
||||
@@ -397,6 +401,15 @@ func argumentAfter(t *testing.T, arguments []string, name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func optionalArgumentAfter(arguments []string, name string) (string, bool) {
|
||||
for index := 0; index+1 < len(arguments); index++ {
|
||||
if arguments[index] == name {
|
||||
return arguments[index+1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func containsArguments(arguments []string, sequence ...string) bool {
|
||||
if len(sequence) == 0 || len(sequence) > len(arguments) {
|
||||
return false
|
||||
|
||||
@@ -91,11 +91,14 @@ func (session *Session) startRuntimeReceivers() error {
|
||||
|
||||
session.receiveDone.Add(1)
|
||||
go session.readMainConnection()
|
||||
if session.securityActive && session.transport == "tcp" && session.protectedTCP != nil {
|
||||
// Vodafone UK (and others) deliver MT SMS as SIP MESSAGE to the
|
||||
// ipsec-3gpp UE server port over UDP even when REGISTER used TCP.
|
||||
// Always read both sockets when they were reserved.
|
||||
if session.securityActive && session.protectedTCP != nil {
|
||||
session.receiveDone.Add(1)
|
||||
go session.acceptProtectedTCP()
|
||||
}
|
||||
if session.securityActive && session.transport == "udp" && session.protectedUDP != nil {
|
||||
if session.securityActive && session.protectedUDP != nil {
|
||||
session.receiveDone.Add(1)
|
||||
go session.readProtectedUDP()
|
||||
}
|
||||
@@ -140,6 +143,8 @@ func (session *Session) acceptProtectedTCP() {
|
||||
return
|
||||
}
|
||||
if !session.validProtectedTCPSource(connection.RemoteAddr()) {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound TCP rejected", nil,
|
||||
"stage", "source_filter", "remote", connection.RemoteAddr().String())
|
||||
_ = connection.Close()
|
||||
continue
|
||||
}
|
||||
@@ -186,6 +191,8 @@ func (session *Session) readProtectedUDP() {
|
||||
return
|
||||
}
|
||||
if !session.validProtectedUDPSource(remote) {
|
||||
session.logInboundSMS(slog.LevelWarn, "IMS inbound UDP rejected", nil,
|
||||
"stage", "source_filter", "remote", remote.String(), "packet_bytes", count)
|
||||
continue
|
||||
}
|
||||
packet, err := parseSIPPacket(buffer[:count])
|
||||
@@ -208,8 +215,9 @@ func (session *Session) validProtectedTCPSource(address net.Addr) bool {
|
||||
return false
|
||||
}
|
||||
expected := addressIP(session.conn.RemoteAddr())
|
||||
return expected != nil && expected.Equal(remote.IP) &&
|
||||
remote.Port == session.securityAgreement.selected.portClient
|
||||
// Require P-CSCF IP. Do not require port-c (50601): some cores originate
|
||||
// MESSAGE from an ephemeral port on the same P-CSCF.
|
||||
return expected != nil && expected.Equal(remote.IP)
|
||||
}
|
||||
|
||||
func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) {
|
||||
@@ -755,7 +763,10 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
||||
|
||||
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
||||
return strings.TrimSpace(config.SMSCenterByPLMN[plmn])
|
||||
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||
return configured
|
||||
}
|
||||
return strings.TrimSpace(vowifi.ResolveCarrierProfile(identity).SMSCenter)
|
||||
}
|
||||
|
||||
func smsRecipientType(recipient string) string {
|
||||
|
||||
@@ -180,6 +180,22 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
mnc string
|
||||
want string
|
||||
}{
|
||||
{mnc: "10", want: "+447802000332"},
|
||||
{mnc: "15", want: "+447785016005"},
|
||||
{mnc: "30", want: ""},
|
||||
} {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
||||
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
|
||||
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
type ATDeviceController interface {
|
||||
@@ -73,6 +74,28 @@ func (mapper ATMapper) ExecuteSensitiveAT(
|
||||
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
||||
}
|
||||
|
||||
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
|
||||
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
|
||||
// without issuing duplicate APDUs on every reconnect.
|
||||
func (mapper ATMapper) ReadSIMMetadata(ctx context.Context, configuredID string) (vowifi.SIMMetadata, error) {
|
||||
physicalID, err := mapper.resolve(ctx, configuredID)
|
||||
if err != nil {
|
||||
return vowifi.SIMMetadata{}, err
|
||||
}
|
||||
entry, err := mapper.Devices.Get(physicalID)
|
||||
if err != nil {
|
||||
return vowifi.SIMMetadata{}, err
|
||||
}
|
||||
if entry.Snapshot == nil {
|
||||
return vowifi.SIMMetadata{}, nil
|
||||
}
|
||||
return vowifi.SIMMetadata{
|
||||
SPN: strings.TrimSpace(entry.Snapshot.SPN),
|
||||
GID1: strings.TrimSpace(entry.Snapshot.GID1),
|
||||
GID2: strings.TrimSpace(entry.Snapshot.GID2),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (mapper ATMapper) resolve(
|
||||
ctx context.Context,
|
||||
configuredID string,
|
||||
|
||||
@@ -32,6 +32,7 @@ func (resolver ProxyResolver) Resolve(
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
var upstreamID string
|
||||
matchedCountryRule := false
|
||||
if iccid != "" {
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if err == nil {
|
||||
@@ -43,7 +44,10 @@ func (resolver ProxyResolver) Resolve(
|
||||
if upstreamID == "" {
|
||||
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
||||
if !found {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
country = strings.ToUpper(strings.TrimSpace(request.CountryCode))
|
||||
if len(country) != 2 {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
}
|
||||
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
||||
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
||||
@@ -53,6 +57,7 @@ func (resolver ProxyResolver) Resolve(
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
||||
}
|
||||
upstreamID = rule.UpstreamProxyID
|
||||
matchedCountryRule = true
|
||||
}
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
||||
if err != nil {
|
||||
@@ -64,12 +69,43 @@ func (resolver ProxyResolver) Resolve(
|
||||
)
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
if matchedCountryRule {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf(
|
||||
"upstream proxy %q for device %s is disabled",
|
||||
upstream.ID,
|
||||
deviceID,
|
||||
)
|
||||
}
|
||||
if matchedCountryRule && iccid != "" {
|
||||
created, bindErr := resolver.Store.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: deviceID,
|
||||
ICCID: iccid,
|
||||
ProfileName: iccid,
|
||||
UpstreamProxyID: upstream.ID,
|
||||
})
|
||||
if bindErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("materialize MCC proxy route for ICCID %s: %w", iccid, bindErr)
|
||||
}
|
||||
if !created {
|
||||
// Another request or an administrator may have created an explicit
|
||||
// binding after our first lookup. The persisted ICCID route wins.
|
||||
binding, bindingErr := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if bindingErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("reload proxy binding for ICCID %s: %w", iccid, bindingErr)
|
||||
}
|
||||
if binding.UpstreamProxyID != upstream.ID {
|
||||
upstream, err = resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("load materialized upstream proxy %q for device %s: %w", binding.UpstreamProxyID, deviceID, err)
|
||||
}
|
||||
if !upstream.Enabled {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("upstream proxy %q for device %s is disabled", upstream.ID, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return vowifi.ProxyRoute{
|
||||
Mode: vowifi.ProxyModeSOCKS5,
|
||||
ID: upstream.ID,
|
||||
@@ -198,6 +234,8 @@ func (projector StateProjector) Save(
|
||||
"pure_airplane_policy": state.PureAirplanePolicy,
|
||||
"home_mcc": state.HomeMCC,
|
||||
"home_mnc": state.HomeMNC,
|
||||
"carrier_profile": state.CarrierProfile,
|
||||
"carrier_profile_from": state.CarrierProfileFrom,
|
||||
"warnings": state.Warnings,
|
||||
"cleanup_errors": state.CleanupErrors,
|
||||
"attempt": state.Attempt,
|
||||
|
||||
@@ -103,6 +103,141 @@ func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverCountryRuleWithDisabledProxyFallsBackDirect(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "disabled", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.Mode != vowifi.ProxyModeDirect {
|
||||
t.Fatalf("route = %#v, want direct for a disabled country default", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
{ID: "first", Name: "First", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "later", Name: "Later", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(ctx, proxy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "first", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
}
|
||||
resolver := ProxyResolver{Store: database}
|
||||
route, err := resolver.Resolve(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "first" {
|
||||
t.Fatalf("first route = %#v, want MCC default", route)
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, request.ICCID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.DeviceID != request.DeviceID || binding.UpstreamProxyID != "first" {
|
||||
t.Fatalf("materialized binding = %#v", binding)
|
||||
}
|
||||
|
||||
if err := database.UpsertCountryRule(ctx, store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "later", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err = resolver.Resolve(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "first" {
|
||||
t.Fatalf("route after country rule edit = %#v, want durable ICCID binding", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
ctx := context.Background()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, proxyID := range []string{"explicit", "default"} {
|
||||
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
|
||||
ID: proxyID, Name: proxyID, Addr: "127.0.0.1:1080", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
iccid := "89441000400128014257"
|
||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := database.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Automatic", UpstreamProxyID: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("default binding unexpectedly replaced an explicit binding")
|
||||
}
|
||||
binding, err := database.DeviceProxyBinding(ctx, iccid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding.UpstreamProxyID != "explicit" || binding.ProfileName != "Manual" {
|
||||
t.Fatalf("binding = %#v, want explicit binding unchanged", binding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
||||
database := testStore(t)
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
@@ -216,13 +351,15 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
||||
}
|
||||
projector := StateProjector{Store: database}
|
||||
if err := projector.Save(context.Background(), vowifi.State{
|
||||
DeviceID: "ec25",
|
||||
Phase: vowifi.PhaseIMSReady,
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
TunnelName: "vocat-swu-ec25",
|
||||
DataplaneMode: "userspace",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
DeviceID: "ec25",
|
||||
Phase: vowifi.PhaseIMSReady,
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
TunnelName: "vocat-swu-ec25",
|
||||
DataplaneMode: "userspace",
|
||||
CarrierProfile: "vodafone-uk",
|
||||
CarrierProfileFrom: "hplmn",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,6 +377,13 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
||||
if tunnel["dataplane_mode"] != "userspace" {
|
||||
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
|
||||
}
|
||||
var extra map[string]any
|
||||
if err := json.Unmarshal(runtime.Extra, &extra); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if extra["carrier_profile"] != "vodafone-uk" || extra["carrier_profile_from"] != "hplmn" {
|
||||
t.Fatalf("carrier profile projection = %#v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
||||
|
||||
@@ -56,6 +56,14 @@ func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID stri
|
||||
return SIMIdentity{}, err
|
||||
}
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
|
||||
if reader, ok := adapter.controller.(SIMMetadataReader); ok {
|
||||
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
}
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return SIMIdentity{}, err
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
||||
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
|
||||
}
|
||||
}
|
||||
carrierProfile := ResolveCarrierProfile(identity)
|
||||
orchestrator.mutate(func(state *State) {
|
||||
state.Phase = PhaseSIMReady
|
||||
state.ICCID = strings.TrimSpace(identity.ICCID)
|
||||
@@ -248,6 +249,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
||||
state.SIMReady = true
|
||||
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
||||
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
||||
state.CarrierProfile = carrierProfile.ID
|
||||
state.CarrierProfileFrom = carrierProfile.MatchSource
|
||||
state.LastReason = "sim_and_aka_ready"
|
||||
})
|
||||
|
||||
@@ -645,8 +648,12 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
|
||||
}
|
||||
return strings.ToLower(configured), nil
|
||||
}
|
||||
if IsATT310280(identity) {
|
||||
return att310280EPDG, nil
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.EPDG != "" {
|
||||
return profile.EPDG, nil
|
||||
}
|
||||
if profile.RouteMCC != "" {
|
||||
return standardEPDGHostname(profile.RouteMCC, profile.RouteMNC), nil
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -53,7 +53,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
mncLength := identity.MNCLength
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC, SPN: identity.SPN}), nil
|
||||
}
|
||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
return applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||
SMSC: identity.SMSC,
|
||||
SMSC: identity.SMSC, SPN: identity.SPN,
|
||||
}), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ type State struct {
|
||||
PureAirplanePolicy bool `json:"pure_airplane_policy"`
|
||||
HomeMCC string `json:"home_mcc,omitempty"`
|
||||
HomeMNC string `json:"home_mnc,omitempty"`
|
||||
CarrierProfile string `json:"carrier_profile,omitempty"`
|
||||
CarrierProfileFrom string `json:"carrier_profile_from,omitempty"`
|
||||
EPDG string `json:"epdg,omitempty"`
|
||||
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
|
||||
ProxyID string `json:"proxy_id,omitempty"`
|
||||
@@ -137,6 +139,9 @@ type SIMIdentity struct {
|
||||
HomeMCC string
|
||||
HomeMNC string
|
||||
HomeCountryCode string
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
EPDG string
|
||||
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
|
||||
// RP-DATA. It is optional during identity discovery, but IMS submission
|
||||
@@ -304,6 +309,22 @@ type SIMIdentityReader interface {
|
||||
ReadIdentity(context.Context, string) (SIMIdentity, error)
|
||||
}
|
||||
|
||||
// SIMMetadata contains optional, non-secret carrier selectors stored by the
|
||||
// UICC. They improve MVNO matching but are never required for AKA or exposed in
|
||||
// the public runtime state.
|
||||
type SIMMetadata struct {
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
}
|
||||
|
||||
// SIMMetadataReader is an optional companion implemented by device mappers
|
||||
// that already cache EF_SPN and EF_GID1/2. Identity readers degrade to PLMN,
|
||||
// IMSI and ICCID matching when it is unavailable.
|
||||
type SIMMetadataReader interface {
|
||||
ReadSIMMetadata(context.Context, string) (SIMMetadata, error)
|
||||
}
|
||||
|
||||
// SMSCenterReader optionally supplies the SIM-configured service-centre
|
||||
// address needed for mobile-originated SMS over IMS.
|
||||
type SMSCenterReader interface {
|
||||
@@ -384,6 +405,7 @@ type Call struct {
|
||||
Direction string `json:"direction"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
AnsweredAt *time.Time `json:"answered_at,omitempty"`
|
||||
SIPCode int `json:"sip_code,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
MediaReady bool `json:"media_ready,omitempty"`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmbeddedDistributionContainsIndex(t *testing.T) {
|
||||
index, err := fs.ReadFile(Dist, "index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded index.html: %v", err)
|
||||
}
|
||||
if len(index) == 0 {
|
||||
t.Fatal("embedded index.html is empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ServerRegular } from "@fluentui/react-icons";
|
||||
import type { DashboardHostInfo } from "../../types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
interface Row {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 宿主机信息卡:CPU / 主板 / 内存 / 硬盘型号,后端一次性探测后缓存。
|
||||
export function HostInfoCard({ info }: { info?: DashboardHostInfo | null }) {
|
||||
const { t } = useI18n();
|
||||
const rows: Row[] = [
|
||||
{ label: t("CPU 型号"), value: info?.cpuModel || "" },
|
||||
{ label: t("主板型号"), value: info?.boardModel || "" },
|
||||
{ label: t("内存型号"), value: info?.memoryModel || "" },
|
||||
{ label: t("硬盘型号"), value: info?.diskModel || "" },
|
||||
];
|
||||
return (
|
||||
<div className="ui-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<ServerRegular className="h-4 w-4 text-sky-500" />
|
||||
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("宿主机信息")}</h3>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-baseline justify-between gap-3">
|
||||
<span className="flex-shrink-0 text-xs text-gray-400">{row.label}</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-right text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
title={row.value || undefined}
|
||||
>
|
||||
{row.value || "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ArrowDownRegular, ArrowUpRegular, GaugeRegular } from "@fluentui/react-icons";
|
||||
import type { DashboardHostPerf } from "../../types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { cx, formatBytes } from "../../lib/utils";
|
||||
|
||||
function percentText(value: number) {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// 利用率越高颜色越危险:<70 绿,<90 黄,其余红。
|
||||
function barColor(percent: number) {
|
||||
if (percent >= 90) return "bg-red-500";
|
||||
if (percent >= 70) return "bg-amber-500";
|
||||
return "bg-emerald-500";
|
||||
}
|
||||
|
||||
function textColor(percent: number) {
|
||||
if (percent >= 90) return "text-red-600 dark:text-red-400";
|
||||
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
}
|
||||
|
||||
function UsageBar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
|
||||
const clamped = Math.min(100, Math.max(0, percent || 0));
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
{detail ? <span className="text-[10px] text-gray-400">{detail}</span> : null}
|
||||
<span className={cx("text-xs font-bold tabular-nums", textColor(clamped))}>{percentText(clamped)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10">
|
||||
<div
|
||||
className={cx("h-full rounded-full transition-all duration-500", barColor(clamped))}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 性能信息卡:CPU / 内存 / 硬盘进度条 + 实时网络上下行速率。
|
||||
export function HostPerfCard({ perf }: { perf?: DashboardHostPerf | null }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="ui-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GaugeRegular className="h-4 w-4 text-sky-500" />
|
||||
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("性能信息")}</h3>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<UsageBar label={t("CPU 使用率")} percent={perf?.cpuPercent ?? 0} />
|
||||
<UsageBar
|
||||
label={t("内存使用率")}
|
||||
percent={perf?.memoryPercent ?? 0}
|
||||
detail={perf && perf.memoryTotalBytes > 0 ? `${formatBytes(perf.memoryUsedBytes)} / ${formatBytes(perf.memoryTotalBytes)}` : undefined}
|
||||
/>
|
||||
<UsageBar
|
||||
label={t("硬盘使用率")}
|
||||
percent={perf?.diskPercent ?? 0}
|
||||
detail={perf && perf.diskTotalBytes > 0 ? `${formatBytes(perf.diskUsedBytes)} / ${formatBytes(perf.diskTotalBytes)}` : undefined}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 pt-0.5">
|
||||
<span className="text-xs text-gray-400">{t("网络")}</span>
|
||||
<span className="flex items-center gap-3 text-xs font-semibold tabular-nums">
|
||||
<span className="flex items-center gap-1 text-sky-600 dark:text-sky-400" title={t("实时上传速率")}>
|
||||
<ArrowUpRegular className="h-3.5 w-3.5" />
|
||||
{formatBytes(perf?.netTxBps ?? 0)}/s
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400" title={t("实时下载速率")}>
|
||||
<ArrowDownRegular className="h-3.5 w-3.5" />
|
||||
{formatBytes(perf?.netRxBps ?? 0)}/s
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
||||
import { useI18n, tf } from "../../lib/i18n";
|
||||
import { cx } from "../../lib/utils";
|
||||
|
||||
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
|
||||
type RateLevel = "green" | "yellow" | "orange" | "red";
|
||||
|
||||
function rateLevel(percent: number): RateLevel {
|
||||
if (percent >= 100) return "green";
|
||||
if (percent >= 80) return "yellow";
|
||||
if (percent >= 50) return "orange";
|
||||
return "red";
|
||||
}
|
||||
|
||||
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
|
||||
green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" },
|
||||
yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" },
|
||||
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
|
||||
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
|
||||
};
|
||||
|
||||
// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。
|
||||
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
||||
const { t } = useI18n();
|
||||
const percent = total > 0 ? Math.round((online / total) * 100) : null;
|
||||
const level = percent === null ? null : rateLevel(percent);
|
||||
const styles = level ? LEVEL_STYLES[level] : null;
|
||||
|
||||
return (
|
||||
<div className="ui-panel p-4">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
|
||||
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
{percent === null ? (
|
||||
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</div>
|
||||
) : (
|
||||
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
|
||||
{percent}
|
||||
<span className="text-2xl">%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{styles ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
|
||||
{t(styles.labelKey)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { CalendarClockRegular } from "@fluentui/react-icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { DashboardUpcomingTask } from "../../types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
function formatRunAt(value: string): string {
|
||||
if (!value || value.startsWith("0001-")) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 将要执行的定时任务卡:按 nextRunAt 升序取前 3 条已启用任务。
|
||||
export function UpcomingTasksCard({ tasks }: { tasks: DashboardUpcomingTask[] }) {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="ui-panel p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClockRegular className="h-4 w-4 text-sky-500" />
|
||||
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("将要执行的定时任务")}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/automatic-tasks")}
|
||||
className="text-xs font-medium text-sky-600 transition-colors hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||
>
|
||||
{t("查看全部")}
|
||||
</button>
|
||||
</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="flex h-[4.5rem] items-center justify-center text-xs text-gray-400">
|
||||
{t("暂无定时任务")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300" title={task.name}>
|
||||
{task.name}
|
||||
</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{formatRunAt(task.nextRunAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
||||
</div>
|
||||
) : null}
|
||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
||||
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { SearchRegular } from "@fluentui/react-icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Country, CountryRule, UpstreamProxy } from "../../types";
|
||||
import { Button, EmptyState, Input, Modal, Select } from "../ui";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface CountryRulesDialogProps {
|
||||
open: boolean;
|
||||
proxies: UpstreamProxy[];
|
||||
countries: Country[];
|
||||
rules: CountryRule[];
|
||||
busy: boolean;
|
||||
onSave: (assignments: Record<string, string>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CountryRulesDialog(props: CountryRulesDialogProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const { open, proxies, countries, rules, busy, onSave, onClose } = props;
|
||||
const [query, setQuery] = useState("");
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
const regionNames = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [lang]);
|
||||
const countryLabel = (country: Country) => regionNames?.of(country.countryCode) || country.countryName || country.countryCode;
|
||||
const proxyOptions = useMemo(() => [
|
||||
{ value: "", label: t("直连") },
|
||||
...proxies.map((proxy) => ({
|
||||
value: proxy.id,
|
||||
label: proxy.enabled ? (proxy.name || proxy.id) : `${proxy.name || proxy.id}(${t("已禁用")})`,
|
||||
disabled: !proxy.enabled,
|
||||
})),
|
||||
], [proxies, t, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setAssignments({});
|
||||
return;
|
||||
}
|
||||
setAssignments(Object.fromEntries(rules.filter((rule) => rule.enabled).map((rule) => [rule.countryCode, rule.upstreamProxyId])));
|
||||
// Sample rules only when opening. Polling must not discard in-progress edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return [...countries]
|
||||
.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b), lang === "zh" ? "zh-CN" : "en"))
|
||||
.filter((country) => {
|
||||
if (!needle) return true;
|
||||
return [country.countryCode, country.countryName, countryLabel(country), ...country.mccs]
|
||||
.some((value) => String(value || "").toLocaleLowerCase().includes(needle));
|
||||
});
|
||||
}, [countries, query, lang, regionNames]);
|
||||
|
||||
const configuredCount = Object.values(assignments).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("MCC 国家规则")}
|
||||
width="max-w-5xl"
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>{t("取消")}</Button>
|
||||
<Button variant="primary" loading={busy} onClick={() => onSave(assignments)}>{t("保存规则")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
|
||||
{t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs text-gray-500">{configuredCount} {t("个国家规则")}</div>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("搜索国家、地区代码或 MCC")}
|
||||
prefix={<SearchRegular />}
|
||||
className="w-full sm:w-72"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-white/10">
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
<table className="w-full min-w-[680px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-[#202027]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("国家 / 地区")}</th>
|
||||
<th className="px-4 py-3">MCC</th>
|
||||
<th className="w-72 px-4 py-3">{t("规则")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||
{filtered.map((country) => (
|
||||
<tr key={country.countryCode} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium">{countryLabel(country)}</span>
|
||||
<span className="ml-2 font-mono text-xs text-gray-400">{country.countryCode}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{country.mccs.join(", ")}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Select
|
||||
value={assignments[country.countryCode] || ""}
|
||||
options={proxyOptions}
|
||||
disabled={busy}
|
||||
onChange={(value) => setAssignments((current) => ({ ...current, [country.countryCode]: value }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filtered.length === 0 ? <EmptyState title={t("没有匹配的国家或 MCC")} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -40,23 +40,35 @@ function ProbeResultPanel({ probe }: { probe: UpstreamProbeResult }) {
|
||||
const { t } = useI18n();
|
||||
const reachable = !!probe.reachable;
|
||||
const handshakeOk = !!probe.handshakeOk;
|
||||
const udpOk = !!probe.udpAssociateOk;
|
||||
const associateOk = !!probe.udpAssociateOk;
|
||||
const udpOk = !!probe.udpExchangeOk;
|
||||
const handshakeState: ProbeState = !reachable ? "pending" : handshakeOk ? "ok" : "fail";
|
||||
const udpState: ProbeState = !handshakeOk ? "pending" : udpOk ? "ok" : "fail";
|
||||
const associateState: ProbeState = !handshakeOk ? "pending" : associateOk ? "ok" : "fail";
|
||||
const udpState: ProbeState = !associateOk ? "pending" : udpOk ? "ok" : "fail";
|
||||
return (
|
||||
<div className="ui-panel-muted space-y-2 rounded-lg p-3">
|
||||
<ProbeRow state={reachable ? "ok" : "fail"} label={t("TCP 连接")} detail={reachable ? t("可连通") : t("无法连接")} />
|
||||
<ProbeRow state={handshakeState} label={t("SOCKS5 握手")} detail={handshakeOk ? authMethodLabel(probe.authMethod) : undefined} />
|
||||
<ProbeRow
|
||||
state={udpState}
|
||||
state={associateState}
|
||||
label={t("UDP Associate(VoWiFi 依赖)")}
|
||||
detail={udpState === "pending" ? undefined : udpOk ? t("支持") : t("不支持")}
|
||||
detail={associateState === "pending" ? undefined : associateOk ? t("已建立") : t("不支持")}
|
||||
/>
|
||||
<ProbeRow
|
||||
state={udpState}
|
||||
label={t("真实 UDP DNS 往返")}
|
||||
detail={udpState === "pending" ? undefined : udpOk ? `${probe.roundTripMs || 0} ms` : t("无返回")}
|
||||
/>
|
||||
{probe.relayAddr ? (
|
||||
<div className="text-[11px] text-gray-400">
|
||||
{t("UDP 中继地址:")}<span className="font-mono">{probe.relayAddr}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{probe.dnsName && probe.dnsServer ? (
|
||||
<div className="text-[11px] text-gray-400">
|
||||
{t("UDP 测试:")}<span className="font-mono">{probe.dnsName} @ {probe.dnsServer}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{probe.hint ? <div className="text-[11px] text-gray-500 dark:text-gray-400">{probe.hint}</div> : null}
|
||||
{probe.error ? <div className="break-all text-[11px] text-red-500">{probe.error}</div> : null}
|
||||
</div>
|
||||
@@ -127,7 +139,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
|
||||
</div>
|
||||
{probe ? (
|
||||
<div className="space-y-3">
|
||||
<SectionHeader tone={probe.udpAssociateOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||
<SectionHeader tone={probe.udpExchangeOk ? "green" : "amber"} title={t("连通性检测结果")} />
|
||||
<ProbeResultPanel probe={probe} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
|
||||
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular, PauseRegular, PlayRegular } from "@fluentui/react-icons";
|
||||
import type { UpstreamProxy } from "../../types";
|
||||
import { Button, Tag } from "../ui";
|
||||
import type { LoadError, UpstreamRow } from "./shared";
|
||||
@@ -12,9 +12,11 @@ export interface UpstreamSectionProps {
|
||||
onEdit: (proxy: UpstreamProxy) => void;
|
||||
onDelete: (proxy: UpstreamProxy) => void;
|
||||
onOpenBindings: (proxy: UpstreamProxy) => void;
|
||||
onToggle: (proxy: UpstreamProxy) => void;
|
||||
toggleBusyId?: string;
|
||||
}
|
||||
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
|
||||
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings, onToggle, toggleBusyId }: UpstreamSectionProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="ui-card overflow-hidden">
|
||||
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t("名称")}</th>
|
||||
<th className="px-4 py-3">{t("协议")}</th>
|
||||
<th className="px-4 py-3">{t("地址")}</th>
|
||||
<th className="px-4 py-3">{t("鉴权")}</th>
|
||||
<th className="px-4 py-3">{t("状态")}</th>
|
||||
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
|
||||
<th className="px-4 py-3">{t("国家规则")}</th>
|
||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
|
||||
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
|
||||
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
|
||||
<td className="px-4 py-3">{row.username || t("无")}</td>
|
||||
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
|
||||
</div>
|
||||
{row.bindingCount}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.countryNames.length ? (
|
||||
<div className="flex max-w-sm flex-wrap gap-1">
|
||||
{row.countryNames.map((countryName) => <Tag key={countryName} type="primary">{countryName}</Tag>)}
|
||||
</div>
|
||||
) : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant={row.enabled ? "warning" : "success"}
|
||||
plain
|
||||
icon={row.enabled ? <PauseRegular /> : <PlayRegular />}
|
||||
loading={toggleBusyId === row.id}
|
||||
onClick={() => onToggle(row)}
|
||||
>{row.enabled ? t("禁用") : t("启用")}</Button>
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
||||
@@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<GlobeRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无上游代理")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
|
||||
@@ -20,8 +20,13 @@ export interface UpstreamProbeResult {
|
||||
reachable?: boolean;
|
||||
handshakeOk?: boolean;
|
||||
udpAssociateOk?: boolean;
|
||||
udpExchangeOk?: boolean;
|
||||
authMethod?: string;
|
||||
relayAddr?: string;
|
||||
dnsServer?: string;
|
||||
dnsName?: string;
|
||||
dnsRcode?: number;
|
||||
roundTripMs?: number;
|
||||
diagnosis?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
@@ -29,6 +34,7 @@ export interface UpstreamProbeResult {
|
||||
|
||||
export interface UpstreamRow extends UpstreamProxy {
|
||||
bindingCount: number;
|
||||
countryNames: string[];
|
||||
}
|
||||
|
||||
export function ipv6Hint(): string {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function PushplusTab({ value, onChange }: ChannelProps<PushplusForm>) {
|
||||
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
||||
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
|
||||
</div>
|
||||
<Field label="Token">
|
||||
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
|
||||
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
|
||||
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
|
||||
import type { BarkForm, EmailForm, HeaderRow, LarkForm, WebhookForm, WecomForm } from "./model";
|
||||
|
||||
const HEADER_LIST_ID = "vocat-webhook-header-names";
|
||||
|
||||
@@ -21,11 +21,11 @@ function hasAnyUrl(urls: string[]): boolean {
|
||||
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
|
||||
}
|
||||
|
||||
function SMSOnlyHint() {
|
||||
function OneWayNotificationHint() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
||||
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function BarkTab({ value, onChange, testing, onTest }: PushChannelProps<B
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SMSOnlyHint />
|
||||
<OneWayNotificationHint />
|
||||
<div className="space-y-4">
|
||||
<UrlListEditor
|
||||
urls={value.urls}
|
||||
@@ -97,7 +97,7 @@ export function EmailTab({ value, onChange, testing, onTest }: PushChannelProps<
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SMSOnlyHint />
|
||||
<OneWayNotificationHint />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
|
||||
<Field label={t("SMTP 主机")} className="sm:col-span-5">
|
||||
@@ -163,7 +163,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SMSOnlyHint />
|
||||
<OneWayNotificationHint />
|
||||
<div className="space-y-4">
|
||||
<UrlListEditor
|
||||
urls={value.urls}
|
||||
@@ -284,7 +284,7 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SMSOnlyHint />
|
||||
<OneWayNotificationHint />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
|
||||
@@ -300,7 +300,81 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
|
||||
label={t("JSON 请求体模板")}
|
||||
hint={
|
||||
<>
|
||||
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
|
||||
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
|
||||
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={value.payloadTemplate}
|
||||
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
|
||||
disabled={off}
|
||||
rows={12}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LarkTab({ value, onChange, testing, onTest }: PushChannelProps<LarkForm>) {
|
||||
const { t, lang } = useI18n();
|
||||
const off = !value.enabled;
|
||||
const complete = !!value.url.trim() && !!value.payloadTemplate.trim() && (!value.signingEnabled || !!value.secret.trim());
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<ChannelHeader
|
||||
title={t("启用飞书 / Lark 群自定义机器人通知")}
|
||||
enabled={value.enabled}
|
||||
onToggle={(enabled) => onChange({ enabled })}
|
||||
actions={
|
||||
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
|
||||
{t("测试通知")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<OneWayNotificationHint />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||
{t("支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。")}
|
||||
</div>
|
||||
<Field label={t("群机器人 Webhook URL")}>
|
||||
<Input
|
||||
value={value.url}
|
||||
onChange={(event) => {
|
||||
const url = event.target.value;
|
||||
onChange(value.url === "********" && url !== value.url ? { url, secret: "" } : { url });
|
||||
}}
|
||||
disabled={off}
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-gray-500">{t("启用签名校验")}</label>
|
||||
<div className="flex h-10 items-center">
|
||||
<Switch checked={value.signingEnabled} onChange={(signingEnabled) => onChange({ signingEnabled })} disabled={off} />
|
||||
</div>
|
||||
</div>
|
||||
{value.signingEnabled ? (
|
||||
<Field
|
||||
label={t("签名密钥 (Secret)")}
|
||||
hint={t("填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。")}
|
||||
>
|
||||
<Input
|
||||
value={value.secret}
|
||||
onChange={(event) => onChange({ secret: event.target.value })}
|
||||
disabled={off}
|
||||
type="password"
|
||||
placeholder={t("群机器人签名密钥")}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field
|
||||
label={t("JSON 请求体模板")}
|
||||
hint={
|
||||
<>
|
||||
{t("支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
|
||||
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ export interface WecomForm {
|
||||
payloadTemplate: string;
|
||||
}
|
||||
|
||||
export interface LarkForm {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
signingEnabled: boolean;
|
||||
secret: string;
|
||||
payloadTemplate: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
@@ -66,6 +74,13 @@ export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||
}
|
||||
}`;
|
||||
|
||||
export const DEFAULT_LARK_PAYLOAD_TEMPLATE = `{
|
||||
"msg_type": "text",
|
||||
"content": {
|
||||
"text": {{message}}
|
||||
}
|
||||
}`;
|
||||
|
||||
export interface NotifyForms {
|
||||
telegram: TelegramForm;
|
||||
webhook: WebhookForm;
|
||||
@@ -73,6 +88,7 @@ export interface NotifyForms {
|
||||
email: EmailForm;
|
||||
pushplus: PushplusForm;
|
||||
wecom: WecomForm;
|
||||
lark: LarkForm;
|
||||
}
|
||||
|
||||
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
|
||||
@@ -148,6 +164,7 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
|
||||
const email = asRecord(data.email);
|
||||
const pushplus = asRecord(data.pushplus);
|
||||
const wecom = asRecord(data.wecom);
|
||||
const lark = asRecord(data.lark);
|
||||
return {
|
||||
telegram: {
|
||||
enabled: !!telegram.enabled,
|
||||
@@ -197,6 +214,13 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
|
||||
urls: strList(wecom.urls),
|
||||
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
|
||||
},
|
||||
lark: {
|
||||
enabled: !!lark.enabled,
|
||||
url: str(lark.url),
|
||||
signingEnabled: !!lark.signingEnabled,
|
||||
secret: lark.signingEnabled ? str(lark.secret) : "",
|
||||
payloadTemplate: str(lark.payloadTemplate ?? lark.payload_template) || DEFAULT_LARK_PAYLOAD_TEMPLATE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,6 +279,24 @@ export function buildWecomPayload(form: WecomForm, forTest = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLarkPayload(form: LarkForm, forTest = false) {
|
||||
const payload: {
|
||||
enabled: boolean;
|
||||
url?: string;
|
||||
signing_enabled: boolean;
|
||||
payload_template: string;
|
||||
secret?: string;
|
||||
} = {
|
||||
enabled: !!form.enabled,
|
||||
signing_enabled: !!form.signingEnabled,
|
||||
payload_template: String(form.payloadTemplate || ""),
|
||||
};
|
||||
const url = forTest ? String(form.url || "").trim() : String(form.url || "");
|
||||
if (url) payload.url = url;
|
||||
if (form.signingEnabled) payload.secret = String(form.secret || "");
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildNotificationsPayload(forms: NotifyForms) {
|
||||
return {
|
||||
telegram: {
|
||||
@@ -276,5 +318,6 @@ export function buildNotificationsPayload(forms: NotifyForms) {
|
||||
webhook: buildWebhookPayload(forms.webhook),
|
||||
bark: buildBarkPayload(forms.bark),
|
||||
wecom: buildWecomPayload(forms.wecom),
|
||||
lark: buildLarkPayload(forms.lark),
|
||||
};
|
||||
}
|
||||
|
||||
+64
-3
@@ -291,6 +291,24 @@ export const EN_DICT: Record<string, string> = {
|
||||
重试: "Retry",
|
||||
暂无设备接入: "No devices connected",
|
||||
请先在设备管理中添加或接管设备: "Add or adopt a device on the Devices page first",
|
||||
宿主机信息: "Host Hardware",
|
||||
"CPU 型号": "CPU Model",
|
||||
主板型号: "Motherboard",
|
||||
内存型号: "Memory",
|
||||
硬盘型号: "Disk",
|
||||
性能信息: "Performance",
|
||||
"CPU 使用率": "CPU Usage",
|
||||
内存使用率: "Memory Usage",
|
||||
硬盘使用率: "Disk Usage",
|
||||
将要执行的定时任务: "Upcoming Scheduled Tasks",
|
||||
查看全部: "View All",
|
||||
暂无定时任务: "No scheduled tasks",
|
||||
模块在线率: "Module Online Rate",
|
||||
优秀: "Excellent",
|
||||
良好: "Good",
|
||||
一般: "Fair",
|
||||
较差: "Poor",
|
||||
"{online}/{total} 台在线": "{online}/{total} online",
|
||||
|
||||
// ---- 设置页:通用 ----
|
||||
管理网关参数与运行信息: "Manage gateway parameters and runtime info",
|
||||
@@ -307,6 +325,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
"Bark 测试失败": "Bark test failed",
|
||||
"Email 测试失败": "Email test failed",
|
||||
"企业微信消息推送测试失败": "WeCom message push test failed",
|
||||
"飞书 / Lark 群机器人通知测试失败": "Feishu / Lark group bot notification test failed",
|
||||
|
||||
// ---- 设置页:安全卡 ----
|
||||
安全: "Security",
|
||||
@@ -376,8 +395,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"启用后会推送新短信,并允许指定管理员通过 Bot 查看状态、切卡、管理 WiFi Calling、发送短信和限时拨号。拨号只执行呼叫并自动挂断,不处理音频。":
|
||||
"When enabled, new SMS messages are pushed and the designated administrator can check status, switch profiles, manage WiFi Calling, send SMS, and place timed calls. Calls only dial and hang up automatically; audio is not processed.",
|
||||
"启用 Pushplus 推送": "Enable Pushplus",
|
||||
"该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。":
|
||||
"This channel only pushes newly received SMS messages and provides no device controls. Every SMS is pushed separately and is not merged by content.",
|
||||
"该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。":
|
||||
"This channel is for one-way notifications only and provides no device controls. New SMS messages are delivered individually, and notification-enabled automatic tasks also send their results.",
|
||||
"例如 123456": "e.g. 123456",
|
||||
"接收短信通知和命令回复的私聊或群组 ID。群组 ID 可以是负数。":
|
||||
"Private chat or group ID that receives SMS notifications and command replies. Group IDs may be negative.",
|
||||
@@ -406,7 +425,9 @@ export const EN_DICT: Record<string, string> = {
|
||||
"启用 Webhook 推送": "Enable Webhook",
|
||||
"企业微信消息推送": "WeCom Message Push",
|
||||
"启用企业微信消息推送": "Enable WeCom Message Push",
|
||||
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
|
||||
"飞书 / Lark 群机器人": "Feishu / Lark Group Bot",
|
||||
"启用飞书 / Lark 群自定义机器人通知": "Enable Feishu / Lark Custom Group Bot Notifications",
|
||||
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信 / 飞书 / Lark 群机器人": "Telegram / Bark / Email / Pushplus / Webhook / WeCom / Feishu / Lark Group Bot",
|
||||
"目标 URLs": "Target URLs",
|
||||
"添加 URL": "Add URL",
|
||||
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
|
||||
@@ -418,6 +439,16 @@ export const EN_DICT: Record<string, string> = {
|
||||
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
|
||||
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
|
||||
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
|
||||
"支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。":
|
||||
"Supports custom group bot Webhooks for Feishu at open.feishu.cn and international Lark at open.larksuite.com; no app is required.",
|
||||
"群机器人 Webhook URL": "Group Bot Webhook URL",
|
||||
启用签名校验: "Enable Signature Verification",
|
||||
"签名密钥 (Secret)": "Signing Secret",
|
||||
"填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。":
|
||||
"Enter the signing secret generated in the group bot security settings. The Webhook URL and secret are treated as sensitive and masked in the UI.",
|
||||
群机器人签名密钥: "Group bot signing secret",
|
||||
"支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如":
|
||||
"Supports a complete Feishu / Lark custom group bot JSON payload. Use variables as JSON values, for example",
|
||||
"分组 (Group)": "Group",
|
||||
"例如 vocat": "e.g. vocat",
|
||||
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
|
||||
@@ -666,6 +697,11 @@ export const EN_DICT: Record<string, string> = {
|
||||
"SIM / 设备": "SIM / Device",
|
||||
"SM-DP+ 地址 *": "SM-DP+ Address *",
|
||||
"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",
|
||||
"USB 路径": "USB Path",
|
||||
"USSD 交互终端": "USSD Interactive Terminal",
|
||||
@@ -780,6 +816,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"改动将在此卡激活后生效": "Changes take effect once this card is activated",
|
||||
"数据未开启": "Data is off",
|
||||
"数据平面": "Data Plane",
|
||||
"运营商配置": "Carrier Profile",
|
||||
"匹配依据": "Profile Match",
|
||||
"方向": "Direction",
|
||||
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
||||
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
||||
@@ -1005,6 +1043,13 @@ export const EN_DICT: Record<string, string> = {
|
||||
"绑定:": "Bound:",
|
||||
"鉴权:": "Auth:",
|
||||
国家规则: "Country Rules",
|
||||
"MCC 国家规则": "MCC Country Rules",
|
||||
规则: "Rule",
|
||||
代理已启用: "Proxy enabled",
|
||||
代理已禁用: "Proxy disabled",
|
||||
切换代理状态失败: "Failed to change proxy status",
|
||||
"代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连":
|
||||
"Proxy disabled. Explicit ICCID bindings stop using this route without falling back to direct; MCC defaults not yet materialized fall back to direct.",
|
||||
新增代理: "Add Proxy",
|
||||
新增实例: "Add Instance",
|
||||
删除规则: "Delete Rule",
|
||||
@@ -1013,6 +1058,22 @@ export const EN_DICT: Record<string, string> = {
|
||||
"UDP 中继地址:": "UDP Relay Address: ",
|
||||
"规则按 SIM 归属 MCC 解析国家。例如 US 会覆盖 MCC 310/311/312/313/314/315/316 等表内分组;没有配置规则的国家默认直连。需要重启 VoWiFi 生效。":
|
||||
"Country is resolved from the SIM home MCC. For example, US covers the listed MCC 310/311/312/313/314/315/316 groups; countries without a rule use direct connection. Restart VoWiFi to take effect.",
|
||||
"未绑定 ICCID 的卡会按 SIM 归属 MCC 匹配国家规则;首次命中后会生成独立的 ICCID 绑定。ICCID 绑定优先,未命中任何规则时直连。":
|
||||
"A SIM without an ICCID binding uses the country rule matching its home MCC. The first match creates an independent ICCID binding. ICCID bindings take priority; otherwise unmatched SIMs connect directly.",
|
||||
"为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。":
|
||||
"Choose a proxy for each country's MCC. Unconfigured MCCs connect directly. Existing ICCID bindings always take priority, and the first country-rule match creates an independent ICCID binding.",
|
||||
"同一国家只能属于一个代理;选择已分配的国家会将它迁移到当前代理。":
|
||||
"Each country can belong to only one proxy. Selecting a country assigned elsewhere moves it to this proxy.",
|
||||
"搜索国家、地区代码或 MCC": "Search country, region code, or MCC",
|
||||
"国家 / 地区": "Country / Region",
|
||||
当前规则: "Current Rule",
|
||||
当前代理: "This Proxy",
|
||||
直连: "Direct",
|
||||
"没有匹配的国家或 MCC": "No matching country or MCC",
|
||||
"管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定":
|
||||
"Manage VoWiFi upstream proxies, MCC country rules, and physical SIM / eSIM profile bindings",
|
||||
"点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。":
|
||||
"Create a SOCKS5 upstream proxy, then configure country rules or ICCID bindings. Unmatched SIMs connect directly by default.",
|
||||
"VoWiFi 通过此 Socks5 代理连接运营商,实现跨区域本地 VoWiFi。":
|
||||
"VoWiFi connects to the carrier through this Socks5 proxy, enabling cross-region local VoWiFi. ",
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import type { DashboardDevice } from "../types";
|
||||
import type { DashboardDevice, DashboardHost, DashboardUpcomingTask } from "../types";
|
||||
import { usePolling } from "../lib/usePolling";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { PageHeader } from "../components/ui/PageHeader";
|
||||
@@ -10,9 +10,17 @@ import { ErrorState } from "../components/ui/ErrorState";
|
||||
import { ListSkeleton } from "../components/ui/ListSkeleton";
|
||||
import { EmptyState } from "../components/ui/EmptyState";
|
||||
import { DeviceCard } from "../components/DeviceCard";
|
||||
import { HostInfoCard } from "../components/dashboard/HostInfoCard";
|
||||
import { HostPerfCard } from "../components/dashboard/HostPerfCard";
|
||||
import { UpcomingTasksCard } from "../components/dashboard/UpcomingTasksCard";
|
||||
import { OnlineRateCard } from "../components/dashboard/OnlineRateCard";
|
||||
|
||||
interface LoadError { message: string; status?: number; method?: string; url?: string }
|
||||
|
||||
// 任务卡只展示最近的三条;定时任务变化慢,轮询间隔比设备/性能数据更宽。
|
||||
const UPCOMING_TASK_COUNT = 3;
|
||||
const TASKS_POLL_INTERVAL = 15000;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
@@ -20,7 +28,8 @@ export default function DashboardPage() {
|
||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||
const [devicesError, setDevicesError] = useState<LoadError | null>(null);
|
||||
const [devicesOkAt, setDevicesOkAt] = useState<number | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<number | null>(null);
|
||||
const [host, setHost] = useState<DashboardHost | null>(null);
|
||||
const [upcomingTasks, setUpcomingTasks] = useState<DashboardUpcomingTask[]>([]);
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
setDevicesLoading(true);
|
||||
@@ -28,9 +37,7 @@ export default function DashboardPage() {
|
||||
const list = await api<DashboardDevice[]>("/dashboard/devices");
|
||||
setDevices(list || []);
|
||||
setDevicesError(null);
|
||||
const now = Date.now();
|
||||
setDevicesOkAt(now);
|
||||
setLastRefresh(now);
|
||||
setDevicesOkAt(Date.now());
|
||||
} catch (e: any) {
|
||||
setDevicesError({ message: e?.message || t("加载失败"), status: e?.status });
|
||||
} finally {
|
||||
@@ -38,11 +45,34 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 宿主机信息 + 性能数据:2s 轮询让网络速率足够"实时"。
|
||||
const fetchHost = useCallback(async () => {
|
||||
try {
|
||||
setHost(await api<DashboardHost>("/dashboard/host"));
|
||||
} catch {
|
||||
/* 宿主机数据失败不打断设备监控;保留上一次成功值。 */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUpcomingTasks = useCallback(async () => {
|
||||
try {
|
||||
const data = await api<{ tasks?: DashboardUpcomingTask[] }>("/automatic-tasks");
|
||||
const upcoming = (data.tasks || [])
|
||||
.filter((task) => task.enabled && task.nextRunAt && !task.nextRunAt.startsWith("0001-"))
|
||||
.sort((a, b) => new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime())
|
||||
.slice(0, UPCOMING_TASK_COUNT);
|
||||
setUpcomingTasks(upcoming);
|
||||
} catch {
|
||||
/* 任务列表加载失败时保留旧数据。 */
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePolling(fetchDevices, 5000);
|
||||
usePolling(fetchHost, 2000);
|
||||
usePolling(fetchUpcomingTasks, TASKS_POLL_INTERVAL);
|
||||
|
||||
const total = devices.length;
|
||||
const online = devices.filter((d) => d?.healthy).length;
|
||||
const offline = Math.max(0, total - online);
|
||||
const openDevice = (id: string) => navigate(`/devices?device=${encodeURIComponent(id)}&tab=overview`);
|
||||
|
||||
return (
|
||||
@@ -52,11 +82,11 @@ export default function DashboardPage() {
|
||||
subtitle={t("实时监测模组检测状态与出口连通性")}
|
||||
actions={<RefreshButton loading={devicesLoading} onClick={fetchDevices} />}
|
||||
/>
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("设备总数")}</div><div className="mt-1 text-2xl font-extrabold">{total}</div></div>
|
||||
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("在线")}</div><div className="mt-1 text-2xl font-extrabold text-green-600 dark:text-green-400">{online}</div></div>
|
||||
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("离线")}</div><div className="mt-1 text-2xl font-extrabold text-red-600 dark:text-red-400">{offline}</div></div>
|
||||
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("最近刷新")}</div><div className="mt-2 font-mono text-sm text-gray-600 dark:text-gray-300">{lastRefresh ? new Date(lastRefresh).toLocaleTimeString() : "--:--:--"}</div></div>
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<HostInfoCard info={host?.host} />
|
||||
<HostPerfCard perf={host?.perf} />
|
||||
<UpcomingTasksCard tasks={upcomingTasks} />
|
||||
<OnlineRateCard online={online} total={total} />
|
||||
</div>
|
||||
{devicesError ? (
|
||||
<ErrorState className="mb-6" title={t("设备列表加载失败")} message={devicesError.message} statusCode={devicesError.status} requestMethod={devicesError.method} requestUrl={devicesError.url} lastSuccessAt={devicesOkAt} retryText={t("重试")} onRetry={fetchDevices} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user