mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 06:43:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2177a6e9a | ||
|
|
115598757a | ||
|
|
297d2c1839 | ||
|
|
7ba30132f9 | ||
|
|
82494f519b | ||
|
|
5eee89a92a |
+8
-16
@@ -721,27 +721,19 @@ func newVoWiFiOrchestrator(
|
|||||||
if apn == "" {
|
if apn == "" {
|
||||||
apn = "ims"
|
apn = "ims"
|
||||||
}
|
}
|
||||||
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||||
|
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||||
}
|
}
|
||||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
// The userspace SWu data plane carries protected P-CSCF signalling over
|
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||||
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
|
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||||
Transport: "tcp",
|
// alternate transport only if no SIP response was observed.
|
||||||
TransportByPLMN: map[string]string{
|
Transport: "tcp",
|
||||||
"23410": "udp",
|
AutoTransportFallback: true,
|
||||||
"234010": "udp",
|
|
||||||
},
|
|
||||||
// Some UK SIM profiles leave EF_SMSP/AT+CSCA empty. Keep fallbacks scoped
|
|
||||||
// to their HPLMN so an O2/giffgaff SIM can never inherit Vodafone's SMSC.
|
|
||||||
SMSCenterByPLMN: map[string]string{
|
|
||||||
"23410": "+447802000332",
|
|
||||||
"234010": "+447802000332",
|
|
||||||
"23415": "+447785016005",
|
|
||||||
"234015": "+447785016005",
|
|
||||||
},
|
|
||||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||||
extra, _ := json.Marshal(map[string]any{
|
extra, _ := json.Marshal(map[string]any{
|
||||||
"transport": "ims",
|
"transport": "ims",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
"vocat/internal/vowifi/integration"
|
"vocat/internal/vowifi/integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,10 @@ func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Contex
|
|||||||
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
|
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id string) (vowifi.SIMMetadata, error) {
|
||||||
|
return mapper.Mapper.ReadSIMMetadata(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
||||||
physical, err := mapper.physical(id)
|
physical, err := mapper.physical(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const quectelVendorID = "2c7c"
|
const (
|
||||||
|
quectelVendorID = "2c7c"
|
||||||
|
djiVendorID = "2ca3"
|
||||||
|
dji4GProductID = "4006"
|
||||||
|
)
|
||||||
|
|
||||||
type SysFSDiscoverer struct {
|
type SysFSDiscoverer struct {
|
||||||
SysRoot string
|
SysRoot string
|
||||||
@@ -70,13 +74,13 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
resolvedDevice = devicePath
|
resolvedDevice = devicePath
|
||||||
}
|
}
|
||||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||||
if vendorID != quectelVendorID {
|
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||||
|
if !isSupportedUSBModem(vendorID, productID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
state := devices[deviceName]
|
state := devices[deviceName]
|
||||||
if state == nil {
|
if state == nil {
|
||||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
|
||||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||||
state = &discoveredUSBDevice{
|
state = &discoveredUSBDevice{
|
||||||
candidate: Candidate{
|
candidate: Candidate{
|
||||||
@@ -141,6 +145,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isSupportedUSBModem(vendorID, productID string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID) ||
|
||||||
|
IsDJI4GUSB(vendorID, productID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
||||||
|
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
||||||
|
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
||||||
|
func IsDJI4GUSB(vendorID, productID string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(vendorID), djiVendorID) &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
||||||
|
}
|
||||||
|
|
||||||
type discoveredWWANDevice struct {
|
type discoveredWWANDevice struct {
|
||||||
index string
|
index string
|
||||||
ports []Port
|
ports []Port
|
||||||
|
|||||||
@@ -2,6 +2,29 @@ package modem
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
func TestSupportedUSBModemIdentity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
vendorID string
|
||||||
|
productID string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "Quectel", vendorID: "2c7c", productID: "0125", want: true},
|
||||||
|
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
|
||||||
|
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
|
||||||
|
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
|
||||||
|
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := isSupportedUSBModem(test.vendorID, test.productID); got != test.want {
|
||||||
|
t.Fatalf("isSupportedUSBModem(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
||||||
ports := []Port{
|
ports := []Port{
|
||||||
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
|||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := session.transport.Drain(); err != nil {
|
if err := drainTransport(ctx, session.transport); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,7 @@ func (session *Session) executePromptLocked(
|
|||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
return response, fmt.Errorf("write %s: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := session.transport.Drain(); err != nil {
|
if err := drainTransport(ctx, session.transport); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +204,7 @@ func (session *Session) executePromptLocked(
|
|||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
||||||
}
|
}
|
||||||
if err := session.transport.Drain(); err != nil {
|
if err := drainTransport(ctx, session.transport); err != nil {
|
||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
||||||
@@ -211,6 +212,21 @@ func (session *Session) executePromptLocked(
|
|||||||
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainTransport retries tcdrain/TCSBRK when the kernel interrupts it with a
|
||||||
|
// signal. go.bug.st/serial already retries EINTR for Read, but its Linux
|
||||||
|
// Drain implementation currently returns the transient error directly.
|
||||||
|
func drainTransport(ctx context.Context, transport Transport) error {
|
||||||
|
for {
|
||||||
|
err := transport.Drain()
|
||||||
|
if !errors.Is(err, syscall.EINTR) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) readFinalLocked(
|
func (session *Session) readFinalLocked(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
started time.Time,
|
started time.Time,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,8 @@ type transcriptTransport struct {
|
|||||||
unexpected error
|
unexpected error
|
||||||
writePartial bool
|
writePartial bool
|
||||||
writeEvents chan string
|
writeEvents chan string
|
||||||
|
drainErrors []error
|
||||||
|
drainCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
||||||
@@ -114,7 +117,17 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) {
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) Drain() error { return nil }
|
func (transport *transcriptTransport) Drain() error {
|
||||||
|
transport.mu.Lock()
|
||||||
|
defer transport.mu.Unlock()
|
||||||
|
transport.drainCount++
|
||||||
|
if len(transport.drainErrors) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := transport.drainErrors[0]
|
||||||
|
transport.drainErrors = transport.drainErrors[1:]
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) ResetInputBuffer() error {
|
func (transport *transcriptTransport) ResetInputBuffer() error {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
@@ -138,6 +151,31 @@ func (transport *transcriptTransport) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionRetriesInterruptedDrain(t *testing.T) {
|
||||||
|
transport := &transcriptTransport{
|
||||||
|
steps: []transportStep{{
|
||||||
|
write: "AT+CSQ\r",
|
||||||
|
chunks: []string{"\r\nAT+CSQ\r\n+CSQ: 24,99\r\nOK\r\n"},
|
||||||
|
}},
|
||||||
|
drainErrors: []error{syscall.EINTR},
|
||||||
|
}
|
||||||
|
session, err := NewSession(transport, SessionOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSession() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := session.Execute(context.Background(), "AT+CSQ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if response.Final != "OK" {
|
||||||
|
t.Fatalf("response final = %q", response.Final)
|
||||||
|
}
|
||||||
|
if transport.drainCount != 2 {
|
||||||
|
t.Fatalf("Drain() calls = %d, want 2", transport.drainCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
||||||
transport := &transcriptTransport{steps: []transportStep{{
|
transport := &transcriptTransport{steps: []transportStep{{
|
||||||
write: "AT+CSQ\r",
|
write: "AT+CSQ\r",
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
||||||
return true
|
return true
|
||||||
|
case "dashboard/host":
|
||||||
|
s.handleDashboardHost(w, r)
|
||||||
|
return true
|
||||||
case "devices":
|
case "devices":
|
||||||
return s.handleDevices(w, r)
|
return s.handleDevices(w, r)
|
||||||
case "devices/discovered":
|
case "devices/discovered":
|
||||||
@@ -387,6 +390,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
|||||||
result = append(result, map[string]any{
|
result = append(result, map[string]any{
|
||||||
"hardware_kind": candidate.HardwareKind,
|
"hardware_kind": candidate.HardwareKind,
|
||||||
"reader_name": candidate.ReaderName,
|
"reader_name": candidate.ReaderName,
|
||||||
|
"device_type": discoveredDeviceType(candidate),
|
||||||
"discovery_key": entry.ID,
|
"discovery_key": entry.ID,
|
||||||
"control_path": controlPath,
|
"control_path": controlPath,
|
||||||
"net_interface": candidate.NetworkInterface,
|
"net_interface": candidate.NetworkInterface,
|
||||||
@@ -1686,56 +1690,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
|||||||
enabled, _ := extra["enabled"].(bool)
|
enabled, _ := extra["enabled"].(bool)
|
||||||
active, _ := extra["active"].(bool)
|
active, _ := extra["active"].(bool)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"device_id": runtime.DeviceID,
|
"device_id": runtime.DeviceID,
|
||||||
"phase": runtime.Phase,
|
"phase": runtime.Phase,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"active": active,
|
"active": active,
|
||||||
"dataplane_mode": runtime.DataplaneMode,
|
"carrier_profile": extra["carrier_profile"],
|
||||||
"iccid": runtime.ICCID,
|
"carrier_profile_from": extra["carrier_profile_from"],
|
||||||
"imsi": runtime.IMSI,
|
"dataplane_mode": runtime.DataplaneMode,
|
||||||
"sim_ready": runtime.SIMReady,
|
"iccid": runtime.ICCID,
|
||||||
"access_ready": runtime.AccessReady,
|
"imsi": runtime.IMSI,
|
||||||
"tunnel_ready": runtime.TunnelReady,
|
"sim_ready": runtime.SIMReady,
|
||||||
"ims_ready": runtime.IMSReady,
|
"access_ready": runtime.AccessReady,
|
||||||
"sms_ready": runtime.SMSReady,
|
"tunnel_ready": runtime.TunnelReady,
|
||||||
"reg_status": runtime.RegStatus,
|
"ims_ready": runtime.IMSReady,
|
||||||
"reg_status_text": runtime.RegStatusText,
|
"sms_ready": runtime.SMSReady,
|
||||||
"network_mode": runtime.NetworkMode,
|
"reg_status": runtime.RegStatus,
|
||||||
"local_phone": runtime.LocalPhone,
|
"reg_status_text": runtime.RegStatusText,
|
||||||
"phone_number_source": runtime.PhoneNumberSource,
|
"network_mode": runtime.NetworkMode,
|
||||||
"last_error_class": runtime.LastErrorClass,
|
"local_phone": runtime.LocalPhone,
|
||||||
"last_error": runtime.LastError,
|
"phone_number_source": runtime.PhoneNumberSource,
|
||||||
"last_reason": runtime.LastReason,
|
"last_error_class": runtime.LastErrorClass,
|
||||||
"updated_at": runtime.UpdatedAt,
|
"last_error": runtime.LastError,
|
||||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
"last_reason": runtime.LastReason,
|
||||||
"imscore": rawJSONObject(runtime.IMSCore),
|
"updated_at": runtime.UpdatedAt,
|
||||||
"smsip": rawJSONObject(runtime.SMSIP),
|
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||||
|
"imscore": rawJSONObject(runtime.IMSCore),
|
||||||
|
"smsip": rawJSONObject(runtime.SMSIP),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"device_id": runtime.DeviceID,
|
"device_id": runtime.DeviceID,
|
||||||
"phase": string(runtime.Phase),
|
"phase": string(runtime.Phase),
|
||||||
"enabled": runtime.Enabled,
|
"enabled": runtime.Enabled,
|
||||||
"active": runtime.Active,
|
"active": runtime.Active,
|
||||||
"dataplane_mode": runtime.DataplaneMode,
|
"carrier_profile": runtime.CarrierProfile,
|
||||||
"iccid": runtime.ICCID,
|
"carrier_profile_from": runtime.CarrierProfileFrom,
|
||||||
"imsi": runtime.IMSI,
|
"dataplane_mode": runtime.DataplaneMode,
|
||||||
"sim_ready": runtime.SIMReady,
|
"iccid": runtime.ICCID,
|
||||||
"access_ready": runtime.AccessReady,
|
"imsi": runtime.IMSI,
|
||||||
"tunnel_ready": runtime.TunnelReady,
|
"sim_ready": runtime.SIMReady,
|
||||||
"ims_ready": runtime.IMSReady,
|
"access_ready": runtime.AccessReady,
|
||||||
"sms_ready": runtime.SMSReady,
|
"tunnel_ready": runtime.TunnelReady,
|
||||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
"ims_ready": runtime.IMSReady,
|
||||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
"sms_ready": runtime.SMSReady,
|
||||||
"network_mode": "Wi-Fi",
|
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||||
"local_phone": runtime.PhoneNumber,
|
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||||
"phone_number_source": runtime.PhoneNumberSource,
|
"network_mode": "Wi-Fi",
|
||||||
"last_error_class": runtime.LastErrorClass,
|
"local_phone": runtime.PhoneNumber,
|
||||||
"last_error": runtime.LastError,
|
"phone_number_source": runtime.PhoneNumberSource,
|
||||||
"last_reason": runtime.LastReason,
|
"last_error_class": runtime.LastErrorClass,
|
||||||
"updated_at": runtime.UpdatedAt,
|
"last_error": runtime.LastError,
|
||||||
|
"last_reason": runtime.LastReason,
|
||||||
|
"updated_at": runtime.UpdatedAt,
|
||||||
"tunnel": map[string]any{
|
"tunnel": map[string]any{
|
||||||
"established": runtime.TunnelReady,
|
"established": runtime.TunnelReady,
|
||||||
"name": runtime.TunnelName,
|
"name": runtime.TunnelName,
|
||||||
@@ -1909,6 +1917,8 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
|||||||
config.NetworkEnabled = false
|
config.NetworkEnabled = false
|
||||||
config.SMSEnabled = true
|
config.SMSEnabled = true
|
||||||
config.VoWiFiEnabled = true
|
config.VoWiFiEnabled = true
|
||||||
|
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||||
|
config.DeviceType = store.DeviceTypeDJI4G
|
||||||
}
|
}
|
||||||
if config.Interface == "" {
|
if config.Interface == "" {
|
||||||
config.Interface = candidate.NetworkInterface
|
config.Interface = candidate.NetworkInterface
|
||||||
@@ -1933,6 +1943,16 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func discoveredDeviceType(candidate modem.Candidate) string {
|
||||||
|
if candidate.HardwareKind == "pcsc" {
|
||||||
|
return store.DeviceTypeUSBSIMReader
|
||||||
|
}
|
||||||
|
if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||||
|
return store.DeviceTypeDJI4G
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
|||||||
@@ -6,10 +6,28 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/modem"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestFillConfigFromPhysicalClassifiesDJI4G(t *testing.T) {
|
||||||
|
config := store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}
|
||||||
|
entry := device.Device{Candidate: modem.Candidate{
|
||||||
|
VendorID: "2ca3",
|
||||||
|
ProductID: "4006",
|
||||||
|
}}
|
||||||
|
|
||||||
|
fillConfigFromPhysical(&config, entry)
|
||||||
|
|
||||||
|
if config.DeviceType != store.DeviceTypeDJI4G {
|
||||||
|
t.Fatalf("device type = %q, want %q", config.DeviceType, store.DeviceTypeDJI4G)
|
||||||
|
}
|
||||||
|
if got := discoveredDeviceType(entry.Candidate); got != store.DeviceTypeDJI4G {
|
||||||
|
t.Fatalf("discovered device type = %q, want %q", got, store.DeviceTypeDJI4G)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||||
database, err := store.Open(context.Background(), ":memory:")
|
database, err := store.Open(context.Background(), ":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ type Server struct {
|
|||||||
updateApplying bool
|
updateApplying bool
|
||||||
https *httpsmode.Manager
|
https *httpsmode.Manager
|
||||||
netTraffic *liveNetTracker
|
netTraffic *liveNetTracker
|
||||||
|
hostStats *hostStatsSampler
|
||||||
publicIPMu sync.RWMutex
|
publicIPMu sync.RWMutex
|
||||||
publicIPs map[string]cachedPublicIP
|
publicIPs map[string]cachedPublicIP
|
||||||
automaticTasks *automaticTaskScheduler
|
automaticTasks *automaticTaskScheduler
|
||||||
@@ -133,6 +134,7 @@ func New(options Options) (*Server, error) {
|
|||||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||||
https: options.HTTPS,
|
https: options.HTTPS,
|
||||||
netTraffic: newLiveNetTracker(),
|
netTraffic: newLiveNetTracker(),
|
||||||
|
hostStats: newHostStatsSampler(),
|
||||||
publicIPs: make(map[string]cachedPublicIP),
|
publicIPs: make(map[string]cachedPublicIP),
|
||||||
updateCheck: update.CheckLatest,
|
updateCheck: update.CheckLatest,
|
||||||
updateApply: update.ApplyLatest,
|
updateApply: update.ApplyLatest,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -94,9 +95,15 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prior, alreadyHad := parts[sequence]
|
// Some IMS stacks hand us a cumulative segment: sequence 2 contains the
|
||||||
changed = !alreadyHad || prior != segmentBody
|
// already-decoded text of sequence 1 followed by its own payload. Keep a
|
||||||
|
// snapshot so normalizing that representation remains idempotent on a later
|
||||||
|
// redelivery of the same segment.
|
||||||
|
previousParts := maps.Clone(parts)
|
||||||
|
normalizeCumulativeConcatParts(previousParts)
|
||||||
parts[sequence] = segmentBody
|
parts[sequence] = segmentBody
|
||||||
|
normalizeCumulativeConcatParts(parts)
|
||||||
|
changed = !maps.Equal(previousParts, parts)
|
||||||
|
|
||||||
sequences := make([]int, 0, len(parts))
|
sequences := make([]int, 0, len(parts))
|
||||||
for n := range parts {
|
for n := range parts {
|
||||||
@@ -130,3 +137,24 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
return joined.String(), json.RawMessage(encoded), changed, nil
|
return joined.String(), json.RawMessage(encoded), changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeCumulativeConcatParts converts cumulative IMS segment bodies back
|
||||||
|
// into ordinary per-segment bodies. It only removes an exact, non-empty prefix
|
||||||
|
// assembled from every preceding sequence starting at 1, and only when the
|
||||||
|
// current value also contains additional text. That deliberately leaves equal
|
||||||
|
// repeated segments and incomplete/out-of-order prefixes untouched.
|
||||||
|
func normalizeCumulativeConcatParts(parts map[int]string) {
|
||||||
|
var prefix strings.Builder
|
||||||
|
for sequence := 1; ; sequence++ {
|
||||||
|
text, ok := parts[sequence]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assembled := prefix.String()
|
||||||
|
if assembled != "" && len(text) > len(assembled) && strings.HasPrefix(text, assembled) {
|
||||||
|
text = strings.TrimPrefix(text, assembled)
|
||||||
|
parts[sequence] = text
|
||||||
|
}
|
||||||
|
prefix.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,62 @@ func TestMergeConcatSegmentRedeliveryIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeConcatSegmentNormalizesCumulativeIMSPart(t *testing.T) {
|
||||||
|
first := strings.Repeat("安全提醒", 17)
|
||||||
|
want := first + "请通过官方渠道核实。"
|
||||||
|
_, extra, _, err := mergeConcatSegment(nil, first, concatExtra(t, 8, 2, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, normalized, changed, err := mergeConcatSegment(extra, want, concatExtra(t, 8, 2, 2))
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("cumulative segment: body=%q changed=%v err=%v", body, changed, err)
|
||||||
|
}
|
||||||
|
if body != want {
|
||||||
|
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redelivering the cumulative wire representation must compare equal to the
|
||||||
|
// normalized stored representation and must not churn the durable row id.
|
||||||
|
body, _, changed, err = mergeConcatSegment(normalized, want, concatExtra(t, 8, 2, 2))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if changed || body != want {
|
||||||
|
t.Fatalf("redelivery: body=%q changed=%v, want %q/false", body, changed, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeConcatSegmentNormalizesCumulativeIMSPartOutOfOrder(t *testing.T) {
|
||||||
|
first := strings.Repeat("甲", 67)
|
||||||
|
want := first + "尾段"
|
||||||
|
_, extra, _, err := mergeConcatSegment(nil, want, concatExtra(t, 12, 2, 2))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _, changed, err := mergeConcatSegment(extra, first, concatExtra(t, 12, 2, 1))
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("out-of-order segment: body=%q changed=%v err=%v", body, changed, err)
|
||||||
|
}
|
||||||
|
if body != want {
|
||||||
|
t.Fatalf("body = %q, want cumulative text once %q", body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeConcatSegmentKeepsEqualRepeatedPart(t *testing.T) {
|
||||||
|
_, extra, _, err := mergeConcatSegment(nil, "重复", concatExtra(t, 13, 2, 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _, _, err := mergeConcatSegment(extra, "重复", concatExtra(t, 13, 2, 2))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body != "重复重复" {
|
||||||
|
t.Fatalf("body = %q, want intentional equal segments preserved", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
||||||
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,52 +1,338 @@
|
|||||||
package vowifi
|
package vowifi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const att310280EPDG = "epdg.epc.att.net"
|
const (
|
||||||
|
CarrierProfileStandard = "standard-3gpp"
|
||||||
|
IKEProposalModern = "modern"
|
||||||
|
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||||
|
IMSProfileStandard = "standard"
|
||||||
|
IMSProfileO2Germany = "o2-germany"
|
||||||
|
IMSProfileATT = "att"
|
||||||
|
)
|
||||||
|
|
||||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
// CarrierProfile contains only interoperability choices that cannot be
|
||||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
// reliably discovered from the SIM or negotiated with the network. All
|
||||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
// protocol layers consume this common result so their carrier handling cannot
|
||||||
// VoWiFi access gateway.
|
// drift into separate MCC/MNC switch statements.
|
||||||
|
type CarrierProfile struct {
|
||||||
|
ID string
|
||||||
|
MatchSource string
|
||||||
|
RouteMCC string
|
||||||
|
RouteMNC string
|
||||||
|
EPDG string
|
||||||
|
IKEProposal string
|
||||||
|
AdvertiseEAPOnly bool
|
||||||
|
IMSTransport string
|
||||||
|
IMSIdentityProfile string
|
||||||
|
IMSRegisterProfile string
|
||||||
|
IMSIPSecEncryption string
|
||||||
|
SMSCenter string
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileDocument struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Profiles []carrierProfileRule `json:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Match carrierProfileMatch `json:"match"`
|
||||||
|
Route carrierProfileRoute `json:"route"`
|
||||||
|
EPDG carrierProfileEPDG `json:"epdg"`
|
||||||
|
IKE carrierProfileIKE `json:"ike"`
|
||||||
|
IMS carrierProfileIMS `json:"ims"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileMatch struct {
|
||||||
|
HomePLMNs []string `json:"home_plmns"`
|
||||||
|
IMSIPrefixes []string `json:"imsi_prefixes"`
|
||||||
|
ICCIDPrefixes []string `json:"iccid_prefixes"`
|
||||||
|
SPNs []string `json:"spns"`
|
||||||
|
GID1Prefixes []string `json:"gid1_prefixes"`
|
||||||
|
GID2Prefixes []string `json:"gid2_prefixes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileRoute struct {
|
||||||
|
MCC string `json:"mcc"`
|
||||||
|
MNC string `json:"mnc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileEPDG struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
DNSHosts []string `json:"dns_hosts"`
|
||||||
|
DNSClientSubnet string `json:"dns_client_subnet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileIKE struct {
|
||||||
|
Proposal string `json:"proposal"`
|
||||||
|
AdvertiseEAPOnly *bool `json:"advertise_eap_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type carrierProfileIMS struct {
|
||||||
|
Transport string `json:"transport"`
|
||||||
|
IdentityProfile string `json:"identity_profile"`
|
||||||
|
RegisterProfile string `json:"register_profile"`
|
||||||
|
IPSecEncryption string `json:"ipsec_encryption"`
|
||||||
|
SMSCenter string `json:"sms_center"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed carrier_profiles.json
|
||||||
|
var carrierProfilesJSON []byte
|
||||||
|
|
||||||
|
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||||
|
|
||||||
|
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||||
|
var document carrierProfileDocument
|
||||||
|
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||||
|
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||||
|
}
|
||||||
|
if document.Version != 1 {
|
||||||
|
panic(fmt.Sprintf("vowifi: unsupported carrier profile version %d", document.Version))
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(document.Profiles))
|
||||||
|
for index := range document.Profiles {
|
||||||
|
rule := &document.Profiles[index]
|
||||||
|
rule.ID = strings.TrimSpace(rule.ID)
|
||||||
|
if rule.ID == "" {
|
||||||
|
panic("vowifi: carrier profile ID is empty")
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[rule.ID]; duplicate {
|
||||||
|
panic("vowifi: duplicate carrier profile " + rule.ID)
|
||||||
|
}
|
||||||
|
seen[rule.ID] = struct{}{}
|
||||||
|
if !validCarrierProfileRule(*rule) {
|
||||||
|
panic("vowifi: invalid carrier profile " + rule.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document.Profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||||
|
match := rule.Match
|
||||||
|
if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||||
|
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, plmn := range match.HomePLMNs {
|
||||||
|
if canonicalPLMNValue(plmn) == "" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
bestScore := -1
|
||||||
|
for _, rule := range builtinCarrierProfiles {
|
||||||
|
score, source, matched := matchCarrierProfile(rule.Match, identity)
|
||||||
|
if !matched || score <= bestScore {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bestScore = score
|
||||||
|
resolved = applyCarrierProfileRule(resolved, rule, source)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalPLMN(mcc, mnc string) string {
|
||||||
|
mcc = strings.TrimSpace(mcc)
|
||||||
|
mnc = strings.TrimSpace(mnc)
|
||||||
|
if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for len(mnc) < 3 {
|
||||||
|
mnc = "0" + mnc
|
||||||
|
}
|
||||||
|
return mcc + mnc
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalPLMNValue(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.ReplaceAll(value, "/", ""))
|
||||||
|
if len(value) != 5 && len(value) != 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return canonicalPLMN(value[:3], value[3:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignedRoutePLMN remains available to callers that only have the legacy
|
||||||
|
// identifier pair. New code resolves the complete SIMIdentity so SPN/GID
|
||||||
|
// selectors can participate.
|
||||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||||
iccid = strings.TrimSpace(iccid)
|
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
|
||||||
imsi = strings.TrimSpace(imsi)
|
if len(identity.IMSI) >= 5 {
|
||||||
switch {
|
identity.HomeMCC = identity.IMSI[:3]
|
||||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
for _, length := range []int{3, 2} {
|
||||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
if len(identity.IMSI) < 3+length {
|
||||||
return "234", "15", true
|
continue
|
||||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
}
|
||||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
identity.HomeMNC = identity.IMSI[3 : 3+length]
|
||||||
// the initial VoWiFi provisioning path.
|
profile := ResolveCarrierProfile(identity)
|
||||||
return "234", "30", true
|
if profile.RouteMCC != "" {
|
||||||
default:
|
return profile.RouteMCC, profile.RouteMNC, true
|
||||||
return "", "", false
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
|
||||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
|
||||||
// drift between protocol layers.
|
|
||||||
func IsATT310280(identity SIMIdentity) bool {
|
func IsATT310280(identity SIMIdentity) bool {
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
|
||||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
|
||||||
imsi := strings.TrimSpace(identity.IMSI)
|
|
||||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||||
if strings.TrimSpace(identity.EPDG) != "" {
|
if strings.TrimSpace(identity.EPDG) != "" {
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
profile := ResolveCarrierProfile(identity)
|
||||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
switch {
|
||||||
|
case profile.EPDG != "":
|
||||||
|
identity.EPDG = profile.EPDG
|
||||||
|
case profile.RouteMCC != "":
|
||||||
|
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||||
}
|
}
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||||
|
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||||
|
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||||
|
func EPDGDNSClientSubnet(host string) string {
|
||||||
|
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
|
for _, rule := range builtinCarrierProfiles {
|
||||||
|
for _, candidate := range rule.EPDG.DNSHosts {
|
||||||
|
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
||||||
|
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func standardEPDGHostname(mcc, mnc string) string {
|
func standardEPDGHostname(mcc, mnc string) string {
|
||||||
mnc = strings.TrimSpace(mnc)
|
mnc = strings.TrimSpace(mnc)
|
||||||
for len(mnc) < 3 {
|
for len(mnc) < 3 {
|
||||||
|
|||||||
@@ -54,3 +54,47 @@ 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 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,73 @@
|
|||||||
|
{
|
||||||
|
"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": "ctexcel-ee-uk",
|
||||||
|
"match": {
|
||||||
|
"home_plmns": ["23433"],
|
||||||
|
"imsi_prefixes": ["23433"],
|
||||||
|
"iccid_prefixes": ["894430"]
|
||||||
|
},
|
||||||
|
"route": { "mcc": "234", "mnc": "30" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "o2-uk",
|
||||||
|
"match": { "home_plmns": ["23410"] },
|
||||||
|
"ims": {
|
||||||
|
"transport": "udp",
|
||||||
|
"sms_center": "+447802000332"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vodafone-germany",
|
||||||
|
"match": { "home_plmns": ["26202"] },
|
||||||
|
"epdg": {
|
||||||
|
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
|
||||||
|
"dns_client_subnet": "109.192.0.0/24"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -173,6 +173,13 @@ func (adapter *EC20Adapter) ReadIdentity(
|
|||||||
HomeMCC: homeMCC,
|
HomeMCC: homeMCC,
|
||||||
HomeMNC: homeMNC,
|
HomeMNC: homeMNC,
|
||||||
}
|
}
|
||||||
|
if reader, ok := adapter.executor.(SIMMetadataReader); ok {
|
||||||
|
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||||
|
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||||
|
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||||
|
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||||
|
}
|
||||||
|
}
|
||||||
identity = applyAssignedCarrierRoute(identity)
|
identity = applyAssignedCarrierRoute(identity)
|
||||||
adapter.mu.Lock()
|
adapter.mu.Lock()
|
||||||
adapter.bindings[iccid] = ec20SIMBinding{
|
adapter.bindings[iccid] = ec20SIMBinding{
|
||||||
|
|||||||
@@ -10,19 +10,12 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const googleDNSOverHTTPS = "https://dns.google/resolve"
|
const googleDNSOverHTTPS = "https://dns.google/resolve"
|
||||||
|
|
||||||
// A small number of operators publish the standard ePDG CNAME globally but
|
|
||||||
// return its A records only when the recursive DNS query appears to originate
|
|
||||||
// in the home country. Keep this list deliberately narrow: ordinary ePDGs must
|
|
||||||
// continue to use the host resolver, and a fallback is attempted only after
|
|
||||||
// that resolver has failed.
|
|
||||||
var geoRestrictedEPDGSubnets = map[string]string{
|
|
||||||
"epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany
|
|
||||||
}
|
|
||||||
|
|
||||||
type dnsOverHTTPSResponse struct {
|
type dnsOverHTTPSResponse struct {
|
||||||
Status int `json:"Status"`
|
Status int `json:"Status"`
|
||||||
Answer []struct {
|
Answer []struct {
|
||||||
@@ -41,7 +34,7 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
subnet := geoRestrictedEPDGSubnets[normalized]
|
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||||
if subnet == "" {
|
if subnet == "" {
|
||||||
if systemErr != nil {
|
if systemErr != nil {
|
||||||
return nil, systemErr
|
return nil, systemErr
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -20,17 +21,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Random io.Reader
|
Random io.Reader
|
||||||
Resolver *net.Resolver
|
Resolver *net.Resolver
|
||||||
Dialer *net.Dialer
|
Dialer *net.Dialer
|
||||||
RootCAs *x509.CertPool
|
RootCAs *x509.CertPool
|
||||||
ResponderPublicKey crypto.PublicKey
|
ResponderPublicKey crypto.PublicKey
|
||||||
ServerName string
|
ServerName string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
Installer ChildSAInstaller
|
Installer ChildSAInstaller
|
||||||
IdentityType uint8
|
IdentityType uint8
|
||||||
APN string
|
APN string
|
||||||
|
AutoProposalFallback bool
|
||||||
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
@@ -42,6 +45,9 @@ func NewProvider(config Config) (*Provider, error) {
|
|||||||
if config.Random == nil {
|
if config.Random == nil {
|
||||||
config.Random = rand.Reader
|
config.Random = rand.Reader
|
||||||
}
|
}
|
||||||
|
if config.Logger == nil {
|
||||||
|
config.Logger = slog.Default()
|
||||||
|
}
|
||||||
if config.Resolver == nil {
|
if config.Resolver == nil {
|
||||||
config.Resolver = net.DefaultResolver
|
config.Resolver = net.DefaultResolver
|
||||||
}
|
}
|
||||||
@@ -77,6 +83,30 @@ func NewProvider(config Config) (*Provider, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
|
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
|
||||||
|
if provider == nil {
|
||||||
|
return nil, errors.New("ike: nil provider")
|
||||||
|
}
|
||||||
|
session, err := provider.start(ctx, request, false)
|
||||||
|
if err == nil || !provider.config.AutoProposalFallback {
|
||||||
|
return session, err
|
||||||
|
}
|
||||||
|
profile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||||
|
if profile.ID != vowifi.CarrierProfileStandard || !retryableLegacyProposal(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
provider.config.Logger.Warn("IKE ePDG rejected modern proposal; trying bounded legacy fallback",
|
||||||
|
"carrier_profile", profile.ID, "from_proposal", vowifi.IKEProposalModern,
|
||||||
|
"to_proposal", vowifi.IKEProposalLegacy, "error", err)
|
||||||
|
session, fallbackErr := provider.start(ctx, request, true)
|
||||||
|
if fallbackErr != nil {
|
||||||
|
return nil, errors.Join(err, fmt.Errorf("ike: legacy proposal fallback failed: %w", fallbackErr))
|
||||||
|
}
|
||||||
|
provider.config.Logger.Info("IKE automatic legacy proposal fallback succeeded",
|
||||||
|
"carrier_profile", profile.ID, "proposal", vowifi.IKEProposalLegacy)
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) start(ctx context.Context, request vowifi.TunnelRequest, forceLegacy bool) (vowifi.TunnelSession, error) {
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
return nil, errors.New("ike: nil provider")
|
return nil, errors.New("ike: nil provider")
|
||||||
}
|
}
|
||||||
@@ -110,8 +140,12 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
group := uint16(dhMODP2048)
|
group := uint16(dhMODP2048)
|
||||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
carrierProfile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
legacyFirst := carrierProfile.IKEProposal == vowifi.IKEProposalLegacy
|
||||||
|
if forceLegacy {
|
||||||
|
legacyFirst = true
|
||||||
|
}
|
||||||
|
advertiseEAPOnly := carrierProfile.AdvertiseEAPOnly
|
||||||
if legacyFirst {
|
if legacyFirst {
|
||||||
group = dhMODP1024
|
group = dhMODP1024
|
||||||
}
|
}
|
||||||
@@ -582,30 +616,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func legacyIKEProfile(mcc, mnc string) bool {
|
|
||||||
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
|
|
||||||
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
|
|
||||||
// that Vodafone NL core; treating them as a generic modern network causes
|
|
||||||
// IKE_SA_INIT to fail before EAP-AKA even begins.
|
|
||||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
|
||||||
return plmn == "23415" || plmn == "2044"
|
|
||||||
}
|
|
||||||
|
|
||||||
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
|
|
||||||
// Android exposes the ePDG authentication method as carrier policy rather
|
|
||||||
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
|
|
||||||
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
|
|
||||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
|
||||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
|
||||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
|
||||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
|
||||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
|
||||||
return plmn == "2623"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildInitialEAPAuth(
|
func buildInitialEAPAuth(
|
||||||
idi payload,
|
idi payload,
|
||||||
requestedIDr payload,
|
requestedIDr payload,
|
||||||
@@ -719,6 +729,27 @@ func decryptAndValidate(
|
|||||||
return header, payloads, nil
|
return header, payloads, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||||
|
|
||||||
|
type invalidKEPayloadError struct {
|
||||||
|
group uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *invalidKEPayloadError) Error() string {
|
||||||
|
if err.group != 0 {
|
||||||
|
return fmt.Sprintf("ike: responder requires DH group %d", err.group)
|
||||||
|
}
|
||||||
|
return "ike: responder reported INVALID_KE_PAYLOAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryableLegacyProposal(err error) bool {
|
||||||
|
if errors.Is(err, errNoProposalChosen) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var invalidKE *invalidKEPayloadError
|
||||||
|
return errors.As(err, &invalidKE) && (invalidKE.group == 0 || invalidKE.group == dhMODP1024)
|
||||||
|
}
|
||||||
|
|
||||||
func rejectFatalNotifications(payloads []payload) error {
|
func rejectFatalNotifications(payloads []payload) error {
|
||||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||||
kind, data, err := parseNotify(item)
|
kind, data, err := parseNotify(item)
|
||||||
@@ -727,12 +758,12 @@ func rejectFatalNotifications(payloads []payload) error {
|
|||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
case notifyNoProposal:
|
case notifyNoProposal:
|
||||||
return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
return errNoProposalChosen
|
||||||
case notifyInvalidKE:
|
case notifyInvalidKE:
|
||||||
if len(data) == 2 {
|
if len(data) == 2 {
|
||||||
return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data))
|
return &invalidKEPayloadError{group: binary.BigEndian.Uint16(data)}
|
||||||
}
|
}
|
||||||
return errors.New("ike: responder reported INVALID_KE_PAYLOAD")
|
return &invalidKEPayloadError{}
|
||||||
}
|
}
|
||||||
if kind < 16384 {
|
if kind < 16384 {
|
||||||
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
|
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
|
||||||
|
|||||||
@@ -25,15 +25,37 @@ func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
|||||||
{mcc: "204", mnc: "04"},
|
{mcc: "204", mnc: "04"},
|
||||||
{mcc: "204", mnc: "004"},
|
{mcc: "204", mnc: "004"},
|
||||||
} {
|
} {
|
||||||
if !legacyIKEProfile(item.mcc, item.mnc) {
|
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
||||||
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
|
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
||||||
|
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if legacyIKEProfile("234", "87") {
|
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
||||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||||
|
for _, err := range []error{
|
||||||
|
errNoProposalChosen,
|
||||||
|
&invalidKEPayloadError{},
|
||||||
|
&invalidKEPayloadError{group: dhMODP1024},
|
||||||
|
} {
|
||||||
|
if !retryableLegacyProposal(err) {
|
||||||
|
t.Errorf("negotiation failure %v was not retryable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, err := range []error{
|
||||||
|
&invalidKEPayloadError{group: dhMODP2048},
|
||||||
|
errors.New("ike: authentication failed"),
|
||||||
|
vowifi.ErrEAPAuthenticationRejected,
|
||||||
|
} {
|
||||||
|
if retryableLegacyProposal(err) {
|
||||||
|
t.Errorf("unsafe failure %v enabled legacy retry", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (reader constantReader) Read(destination []byte) (int, error) {
|
func (reader constantReader) Read(destination []byte) (int, error) {
|
||||||
for index := range destination {
|
for index := range destination {
|
||||||
destination[index] = reader.value
|
destination[index] = reader.value
|
||||||
|
|||||||
@@ -171,11 +171,12 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
|||||||
|
|
||||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||||
for _, mnc := range []string{"03", "003"} {
|
for _, mnc := range []string{"03", "003"} {
|
||||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
||||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
||||||
|
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
||||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-53
@@ -36,20 +36,24 @@ var (
|
|||||||
// LocalAddress is empty, Provider uses the corresponding value proven by the
|
// LocalAddress is empty, Provider uses the corresponding value proven by the
|
||||||
// TunnelSession. The default transport is TCP and the default port is 5060.
|
// TunnelSession. The default transport is TCP and the default port is 5060.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
PCSCF string
|
PCSCF string
|
||||||
LocalAddress string
|
LocalAddress string
|
||||||
Transport string
|
Transport string
|
||||||
TransportByPLMN map[string]string
|
TransportByPLMN map[string]string
|
||||||
Port int
|
// AutoTransportFallback tries the alternate TCP/UDP transport only when
|
||||||
RegistrationExpiry time.Duration
|
// the initial P-CSCF attempt produced no SIP response at all. A challenge
|
||||||
TransactionTimeout time.Duration
|
// or rejection is authoritative and is never retried as another transport.
|
||||||
PrivateIdentity string
|
AutoTransportFallback bool
|
||||||
PublicIdentity string
|
Port int
|
||||||
UserAgent string
|
RegistrationExpiry time.Duration
|
||||||
SecurityMode SecurityMode
|
TransactionTimeout time.Duration
|
||||||
IPSecInstaller IPSecSAInstaller
|
PrivateIdentity string
|
||||||
ProtectedClientPort int
|
PublicIdentity string
|
||||||
ProtectedServerPort int
|
UserAgent string
|
||||||
|
SecurityMode SecurityMode
|
||||||
|
IPSecInstaller IPSecSAInstaller
|
||||||
|
ProtectedClientPort int
|
||||||
|
ProtectedServerPort int
|
||||||
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
|
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
|
||||||
// and AT+CSCA empty. It must be an international or national digit string.
|
// and AT+CSCA empty. It must be an international or national digit string.
|
||||||
SMSCenter string
|
SMSCenter string
|
||||||
@@ -71,9 +75,11 @@ type Config struct {
|
|||||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||||
// runtime dependency outside the Go standard library.
|
// runtime dependency outside the Go standard library.
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
aka vowifi.AKAProvider
|
aka vowifi.AKAProvider
|
||||||
config Config
|
config Config
|
||||||
installer IPSecSAInstaller
|
installer IPSecSAInstaller
|
||||||
|
transportMu sync.RWMutex
|
||||||
|
transportCache map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||||
@@ -88,7 +94,10 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
|||||||
if installer == nil {
|
if installer == nil {
|
||||||
installer = defaultIPSecInstaller()
|
installer = defaultIPSecInstaller()
|
||||||
}
|
}
|
||||||
return &Provider{aka: aka, config: normalized, installer: installer}, nil
|
return &Provider{
|
||||||
|
aka: aka, config: normalized, installer: installer,
|
||||||
|
transportCache: make(map[string]string),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeConfig(config Config) (Config, error) {
|
func normalizeConfig(config Config) (Config, error) {
|
||||||
@@ -224,10 +233,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
||||||
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||||
}
|
}
|
||||||
transport := transportForIdentity(provider.config, request.Identity)
|
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
|
||||||
if transport == "" {
|
if cached := provider.cachedTransport(request.Identity); cached != "" {
|
||||||
|
transport = cached
|
||||||
|
carrierSelected = true
|
||||||
|
}
|
||||||
|
if transport == "" && !carrierSelected {
|
||||||
transport = transportHint
|
transport = transportHint
|
||||||
}
|
}
|
||||||
|
if transport == "" {
|
||||||
|
transport = provider.config.Transport
|
||||||
|
}
|
||||||
if transport == "" {
|
if transport == "" {
|
||||||
transport = "tcp"
|
transport = "tcp"
|
||||||
}
|
}
|
||||||
@@ -250,31 +266,96 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
||||||
}
|
}
|
||||||
|
|
||||||
connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address())
|
transports := []string{transport}
|
||||||
if err != nil {
|
if provider.config.AutoTransportFallback {
|
||||||
return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err)
|
alternate := "udp"
|
||||||
|
if transport == "udp" {
|
||||||
|
alternate = "tcp"
|
||||||
|
}
|
||||||
|
transports = append(transports, alternate)
|
||||||
}
|
}
|
||||||
session, err := newSession(provider, request, identities, endpoint, transport, connection)
|
var lastErr error
|
||||||
if err != nil {
|
for attempt, candidate := range transports {
|
||||||
_ = connection.Close()
|
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||||
return nil, err
|
if dialErr != nil {
|
||||||
}
|
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
||||||
if err := session.establish(ctx); err != nil {
|
if attempt+1 < len(transports) && ctx.Err() == nil {
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||||
|
if sessionErr != nil {
|
||||||
|
_ = connection.Close()
|
||||||
|
return nil, sessionErr
|
||||||
|
}
|
||||||
|
establishErr := session.establish(ctx)
|
||||||
|
if establishErr == nil {
|
||||||
|
provider.rememberTransport(request.Identity, candidate)
|
||||||
|
if attempt > 0 {
|
||||||
|
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
||||||
|
"transport", candidate)
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
sipResponseObserved := session.evidence.LastSIPCode != 0
|
||||||
session.abort()
|
session.abort()
|
||||||
return nil, err
|
lastErr = establishErr
|
||||||
|
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||||
}
|
}
|
||||||
return session, nil
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
if transport, selected := carrierTransportForIdentity(config, identity); selected {
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
|
||||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
|
||||||
return transport
|
return transport
|
||||||
}
|
}
|
||||||
return config.Transport
|
return config.Transport
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func carrierTransportForIdentity(config Config, identity vowifi.SIMIdentity) (string, bool) {
|
||||||
|
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||||
|
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||||
|
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||||
|
return transport, true
|
||||||
|
}
|
||||||
|
if transport := vowifi.ResolveCarrierProfile(identity).IMSTransport; transport != "" {
|
||||||
|
return transport, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func transportCacheKey(identity vowifi.SIMIdentity) string {
|
||||||
|
if iccid := strings.TrimSpace(identity.ICCID); iccid != "" {
|
||||||
|
return "iccid:" + iccid
|
||||||
|
}
|
||||||
|
return "plmn:" + strings.TrimSpace(identity.HomeMCC) + "/" + strings.TrimSpace(identity.HomeMNC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) cachedTransport(identity vowifi.SIMIdentity) string {
|
||||||
|
provider.transportMu.RLock()
|
||||||
|
transport := provider.transportCache[transportCacheKey(identity)]
|
||||||
|
provider.transportMu.RUnlock()
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) rememberTransport(identity vowifi.SIMIdentity, transport string) {
|
||||||
|
provider.transportMu.Lock()
|
||||||
|
provider.transportCache[transportCacheKey(identity)] = transport
|
||||||
|
provider.transportMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) logTransportFallback(identity vowifi.SIMIdentity, from, to string, err error) {
|
||||||
|
provider.config.Logger.Warn("IMS P-CSCF did not respond; trying alternate SIP transport",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(identity).ID,
|
||||||
|
"from_transport", from, "to_transport", to, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
type identitySet struct {
|
type identitySet struct {
|
||||||
domain string
|
domain string
|
||||||
private string
|
private string
|
||||||
@@ -298,7 +379,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
|||||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||||
privateDomain := domain
|
privateDomain := domain
|
||||||
publicDomain := domain
|
publicDomain := domain
|
||||||
if vowifi.IsATT310280(identity) {
|
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
|
||||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||||
// the generic 3GPP PLMN IMS domain.
|
// the generic 3GPP PLMN IMS domain.
|
||||||
domain = "one.att.net"
|
domain = "one.att.net"
|
||||||
@@ -620,18 +701,11 @@ func newSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||||
if usesO2GermanyIMSProfile(identity) {
|
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
||||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
|
||||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
|
||||||
return "null"
|
|
||||||
}
|
|
||||||
return "aes-cbc"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
|
||||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
|
||||||
return mcc+mnc == "2623"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) abort() {
|
func (session *Session) abort() {
|
||||||
@@ -951,19 +1025,33 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
method: "REGISTER",
|
method: "REGISTER",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
transactionDeadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(transactionDeadline) {
|
||||||
deadline = contextDeadline
|
transactionDeadline = contextDeadline
|
||||||
}
|
}
|
||||||
readUDP := session.protectedUDP
|
readUDP := session.protectedUDP
|
||||||
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
|
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
|
||||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
setReadDeadline := func(deadline time.Time) error {
|
||||||
return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||||
}
|
return fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||||
if protectedUDP {
|
|
||||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
|
||||||
return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
|
||||||
}
|
}
|
||||||
|
if protectedUDP {
|
||||||
|
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||||
|
return fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
retransmitInterval := time.Duration(0)
|
||||||
|
readDeadline := transactionDeadline
|
||||||
|
if session.transport == "udp" {
|
||||||
|
retransmitInterval = sipMessageRetransmitT1
|
||||||
|
if candidate := time.Now().Add(retransmitInterval); candidate.Before(readDeadline) {
|
||||||
|
readDeadline = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := setReadDeadline(readDeadline); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
stopCancellation := context.AfterFunc(ctx, func() {
|
stopCancellation := context.AfterFunc(ctx, func() {
|
||||||
_ = session.conn.SetDeadline(time.Now())
|
_ = session.conn.SetDeadline(time.Now())
|
||||||
@@ -976,6 +1064,7 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
|
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retransmissions := 0
|
||||||
for {
|
for {
|
||||||
var response *sipResponse
|
var response *sipResponse
|
||||||
var err error
|
var err error
|
||||||
@@ -1004,6 +1093,31 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
if contextErr := ctx.Err(); contextErr != nil {
|
if contextErr := ctx.Err(); contextErr != nil {
|
||||||
return nil, contextErr
|
return nil, contextErr
|
||||||
}
|
}
|
||||||
|
var networkErr net.Error
|
||||||
|
if retransmitInterval > 0 && errors.As(err, &networkErr) && networkErr.Timeout() &&
|
||||||
|
time.Now().Before(transactionDeadline) {
|
||||||
|
retransmitInterval *= 2
|
||||||
|
if retransmitInterval > sipMessageRetransmitMax {
|
||||||
|
retransmitInterval = sipMessageRetransmitMax
|
||||||
|
}
|
||||||
|
nextDeadline := time.Now().Add(retransmitInterval)
|
||||||
|
if nextDeadline.After(transactionDeadline) {
|
||||||
|
nextDeadline = transactionDeadline
|
||||||
|
}
|
||||||
|
// The previous read deadline has already expired and net.Conn applies
|
||||||
|
// it to writes too. Extend it before retransmitting.
|
||||||
|
if deadlineErr := setReadDeadline(nextDeadline); deadlineErr != nil {
|
||||||
|
return nil, deadlineErr
|
||||||
|
}
|
||||||
|
if _, writeErr := session.conn.Write(request); writeErr != nil {
|
||||||
|
return nil, fmt.Errorf("ims: retransmit SIP REGISTER: %w", writeErr)
|
||||||
|
}
|
||||||
|
retransmissions++
|
||||||
|
session.provider.config.Logger.Debug("IMS SIP REGISTER retransmitted",
|
||||||
|
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||||
|
"transport", session.transport, "attempt", retransmissions)
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
|
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
|
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
|
||||||
@@ -1014,6 +1128,10 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
||||||
|
retransmitInterval = 0
|
||||||
|
if err := setReadDeadline(transactionDeadline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +20,40 @@ type evidenceTunnel struct {
|
|||||||
evidence vowifi.TunnelEvidence
|
evidence vowifi.TunnelEvidence
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type immediateTimeoutError struct{}
|
||||||
|
|
||||||
|
func (immediateTimeoutError) Error() string { return "test timeout" }
|
||||||
|
func (immediateTimeoutError) Timeout() bool { return true }
|
||||||
|
func (immediateTimeoutError) Temporary() bool { return true }
|
||||||
|
|
||||||
|
type registerRetransmitConn struct {
|
||||||
|
writes int
|
||||||
|
response []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *registerRetransmitConn) Read(destination []byte) (int, error) {
|
||||||
|
if connection.writes < 2 {
|
||||||
|
return 0, immediateTimeoutError{}
|
||||||
|
}
|
||||||
|
return copy(destination, connection.response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (connection *registerRetransmitConn) Write(source []byte) (int, error) {
|
||||||
|
connection.writes++
|
||||||
|
return len(source), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*registerRetransmitConn) Close() error { return nil }
|
||||||
|
func (*registerRetransmitConn) LocalAddr() net.Addr {
|
||||||
|
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
|
||||||
|
}
|
||||||
|
func (*registerRetransmitConn) RemoteAddr() net.Addr {
|
||||||
|
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
|
||||||
|
}
|
||||||
|
func (*registerRetransmitConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (*registerRetransmitConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (*registerRetransmitConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
|
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
|
||||||
return tunnel.evidence
|
return tunnel.evidence
|
||||||
}
|
}
|
||||||
@@ -73,6 +109,62 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||||
|
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
||||||
|
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
||||||
|
}
|
||||||
|
if got := transportForIdentity(Config{
|
||||||
|
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
||||||
|
}, identity); got != "tcp" {
|
||||||
|
t.Fatalf("explicit configuration did not override profile: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderCachesSuccessfulTransportPerSIM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
provider := &Provider{transportCache: make(map[string]string)}
|
||||||
|
first := vowifi.SIMIdentity{ICCID: "8901000000000000001", HomeMCC: "001", HomeMNC: "01"}
|
||||||
|
second := vowifi.SIMIdentity{ICCID: "8901000000000000002", HomeMCC: "001", HomeMNC: "01"}
|
||||||
|
provider.rememberTransport(first, "udp")
|
||||||
|
if got := provider.cachedTransport(first); got != "udp" {
|
||||||
|
t.Fatalf("cached first transport = %q", got)
|
||||||
|
}
|
||||||
|
if got := provider.cachedTransport(second); got != "" {
|
||||||
|
t.Fatalf("second SIM inherited cached transport %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPRegisterRetransmitsBeforeTransactionTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
connection := ®isterRetransmitConn{response: []byte(strings.Join([]string{
|
||||||
|
"SIP/2.0 200 OK",
|
||||||
|
"Call-ID: register-retransmit-test",
|
||||||
|
"CSeq: 7 REGISTER",
|
||||||
|
"Content-Length: 0",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
}, "\r\n"))}
|
||||||
|
session := &Session{
|
||||||
|
provider: &Provider{config: Config{
|
||||||
|
TransactionTimeout: 3 * time.Second,
|
||||||
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
}},
|
||||||
|
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"}},
|
||||||
|
transport: "udp",
|
||||||
|
conn: connection,
|
||||||
|
callID: "register-retransmit-test",
|
||||||
|
}
|
||||||
|
response, err := session.exchange(context.Background(), []byte("REGISTER test"), 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != 200 || connection.writes != 2 {
|
||||||
|
t.Fatalf("response=%#v writes=%d, want SIP 200 after one retransmission", response, connection.writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
|
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
|
||||||
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
|
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
|
||||||
" 23410 ": " +447802000332 ",
|
" 23410 ": " +447802000332 ",
|
||||||
|
|||||||
@@ -755,7 +755,10 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
|||||||
|
|
||||||
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||||
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
||||||
return strings.TrimSpace(config.SMSCenterByPLMN[plmn])
|
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(vowifi.ResolveCarrierProfile(identity).SMSCenter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func smsRecipientType(recipient string) string {
|
func smsRecipientType(recipient string) string {
|
||||||
|
|||||||
@@ -180,6 +180,22 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
mnc string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{mnc: "10", want: "+447802000332"},
|
||||||
|
{mnc: "15", want: "+447785016005"},
|
||||||
|
{mnc: "30", want: ""},
|
||||||
|
} {
|
||||||
|
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
||||||
|
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
|
||||||
|
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
|
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
"vocat/internal/modem"
|
"vocat/internal/modem"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ATDeviceController interface {
|
type ATDeviceController interface {
|
||||||
@@ -73,6 +74,28 @@ func (mapper ATMapper) ExecuteSensitiveAT(
|
|||||||
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
|
||||||
|
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
|
||||||
|
// without issuing duplicate APDUs on every reconnect.
|
||||||
|
func (mapper ATMapper) ReadSIMMetadata(ctx context.Context, configuredID string) (vowifi.SIMMetadata, error) {
|
||||||
|
physicalID, err := mapper.resolve(ctx, configuredID)
|
||||||
|
if err != nil {
|
||||||
|
return vowifi.SIMMetadata{}, err
|
||||||
|
}
|
||||||
|
entry, err := mapper.Devices.Get(physicalID)
|
||||||
|
if err != nil {
|
||||||
|
return vowifi.SIMMetadata{}, err
|
||||||
|
}
|
||||||
|
if entry.Snapshot == nil {
|
||||||
|
return vowifi.SIMMetadata{}, nil
|
||||||
|
}
|
||||||
|
return vowifi.SIMMetadata{
|
||||||
|
SPN: strings.TrimSpace(entry.Snapshot.SPN),
|
||||||
|
GID1: strings.TrimSpace(entry.Snapshot.GID1),
|
||||||
|
GID2: strings.TrimSpace(entry.Snapshot.GID2),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (mapper ATMapper) resolve(
|
func (mapper ATMapper) resolve(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
configuredID string,
|
configuredID string,
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ func (projector StateProjector) Save(
|
|||||||
"pure_airplane_policy": state.PureAirplanePolicy,
|
"pure_airplane_policy": state.PureAirplanePolicy,
|
||||||
"home_mcc": state.HomeMCC,
|
"home_mcc": state.HomeMCC,
|
||||||
"home_mnc": state.HomeMNC,
|
"home_mnc": state.HomeMNC,
|
||||||
|
"carrier_profile": state.CarrierProfile,
|
||||||
|
"carrier_profile_from": state.CarrierProfileFrom,
|
||||||
"warnings": state.Warnings,
|
"warnings": state.Warnings,
|
||||||
"cleanup_errors": state.CleanupErrors,
|
"cleanup_errors": state.CleanupErrors,
|
||||||
"attempt": state.Attempt,
|
"attempt": state.Attempt,
|
||||||
|
|||||||
@@ -351,13 +351,15 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
projector := StateProjector{Store: database}
|
projector := StateProjector{Store: database}
|
||||||
if err := projector.Save(context.Background(), vowifi.State{
|
if err := projector.Save(context.Background(), vowifi.State{
|
||||||
DeviceID: "ec25",
|
DeviceID: "ec25",
|
||||||
Phase: vowifi.PhaseIMSReady,
|
Phase: vowifi.PhaseIMSReady,
|
||||||
TunnelReady: true,
|
TunnelReady: true,
|
||||||
IMSReady: true,
|
IMSReady: true,
|
||||||
TunnelName: "vocat-swu-ec25",
|
TunnelName: "vocat-swu-ec25",
|
||||||
DataplaneMode: "userspace",
|
DataplaneMode: "userspace",
|
||||||
UpdatedAt: time.Now().UTC(),
|
CarrierProfile: "vodafone-uk",
|
||||||
|
CarrierProfileFrom: "hplmn",
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -375,6 +377,13 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
|||||||
if tunnel["dataplane_mode"] != "userspace" {
|
if tunnel["dataplane_mode"] != "userspace" {
|
||||||
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
|
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
|
||||||
}
|
}
|
||||||
|
var extra map[string]any
|
||||||
|
if err := json.Unmarshal(runtime.Extra, &extra); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if extra["carrier_profile"] != "vodafone-uk" || extra["carrier_profile_from"] != "hplmn" {
|
||||||
|
t.Fatalf("carrier profile projection = %#v", extra)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID stri
|
|||||||
return SIMIdentity{}, err
|
return SIMIdentity{}, err
|
||||||
}
|
}
|
||||||
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
|
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
|
||||||
|
if reader, ok := adapter.controller.(SIMMetadataReader); ok {
|
||||||
|
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||||
|
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||||
|
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||||
|
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||||
|
identity = applyAssignedCarrierRoute(identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := identity.validate(); err != nil {
|
if err := identity.validate(); err != nil {
|
||||||
return SIMIdentity{}, err
|
return SIMIdentity{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
|||||||
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
|
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
carrierProfile := ResolveCarrierProfile(identity)
|
||||||
orchestrator.mutate(func(state *State) {
|
orchestrator.mutate(func(state *State) {
|
||||||
state.Phase = PhaseSIMReady
|
state.Phase = PhaseSIMReady
|
||||||
state.ICCID = strings.TrimSpace(identity.ICCID)
|
state.ICCID = strings.TrimSpace(identity.ICCID)
|
||||||
@@ -248,6 +249,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
|||||||
state.SIMReady = true
|
state.SIMReady = true
|
||||||
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
||||||
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
||||||
|
state.CarrierProfile = carrierProfile.ID
|
||||||
|
state.CarrierProfileFrom = carrierProfile.MatchSource
|
||||||
state.LastReason = "sim_and_aka_ready"
|
state.LastReason = "sim_and_aka_ready"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -645,8 +648,12 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
|
|||||||
}
|
}
|
||||||
return strings.ToLower(configured), nil
|
return strings.ToLower(configured), nil
|
||||||
}
|
}
|
||||||
if IsATT310280(identity) {
|
profile := ResolveCarrierProfile(identity)
|
||||||
return att310280EPDG, nil
|
if profile.EPDG != "" {
|
||||||
|
return profile.EPDG, nil
|
||||||
|
}
|
||||||
|
if profile.RouteMCC != "" {
|
||||||
|
return standardEPDGHostname(profile.RouteMCC, profile.RouteMNC), nil
|
||||||
}
|
}
|
||||||
if err := identity.validate(); err != nil {
|
if err := identity.validate(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
|||||||
mncLength := identity.MNCLength
|
mncLength := identity.MNCLength
|
||||||
if mncLength != 2 && mncLength != 3 {
|
if mncLength != 2 && mncLength != 3 {
|
||||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
|
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC, SPN: identity.SPN}), nil
|
||||||
}
|
}
|
||||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
|||||||
return applyAssignedCarrierRoute(SIMIdentity{
|
return applyAssignedCarrierRoute(SIMIdentity{
|
||||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||||
SMSC: identity.SMSC,
|
SMSC: identity.SMSC, SPN: identity.SPN,
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ type State struct {
|
|||||||
PureAirplanePolicy bool `json:"pure_airplane_policy"`
|
PureAirplanePolicy bool `json:"pure_airplane_policy"`
|
||||||
HomeMCC string `json:"home_mcc,omitempty"`
|
HomeMCC string `json:"home_mcc,omitempty"`
|
||||||
HomeMNC string `json:"home_mnc,omitempty"`
|
HomeMNC string `json:"home_mnc,omitempty"`
|
||||||
|
CarrierProfile string `json:"carrier_profile,omitempty"`
|
||||||
|
CarrierProfileFrom string `json:"carrier_profile_from,omitempty"`
|
||||||
EPDG string `json:"epdg,omitempty"`
|
EPDG string `json:"epdg,omitempty"`
|
||||||
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
|
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
|
||||||
ProxyID string `json:"proxy_id,omitempty"`
|
ProxyID string `json:"proxy_id,omitempty"`
|
||||||
@@ -137,6 +139,9 @@ type SIMIdentity struct {
|
|||||||
HomeMCC string
|
HomeMCC string
|
||||||
HomeMNC string
|
HomeMNC string
|
||||||
HomeCountryCode string
|
HomeCountryCode string
|
||||||
|
SPN string
|
||||||
|
GID1 string
|
||||||
|
GID2 string
|
||||||
EPDG string
|
EPDG string
|
||||||
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
|
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
|
||||||
// RP-DATA. It is optional during identity discovery, but IMS submission
|
// RP-DATA. It is optional during identity discovery, but IMS submission
|
||||||
@@ -304,6 +309,22 @@ type SIMIdentityReader interface {
|
|||||||
ReadIdentity(context.Context, string) (SIMIdentity, error)
|
ReadIdentity(context.Context, string) (SIMIdentity, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SIMMetadata contains optional, non-secret carrier selectors stored by the
|
||||||
|
// UICC. They improve MVNO matching but are never required for AKA or exposed in
|
||||||
|
// the public runtime state.
|
||||||
|
type SIMMetadata struct {
|
||||||
|
SPN string
|
||||||
|
GID1 string
|
||||||
|
GID2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIMMetadataReader is an optional companion implemented by device mappers
|
||||||
|
// that already cache EF_SPN and EF_GID1/2. Identity readers degrade to PLMN,
|
||||||
|
// IMSI and ICCID matching when it is unavailable.
|
||||||
|
type SIMMetadataReader interface {
|
||||||
|
ReadSIMMetadata(context.Context, string) (SIMMetadata, error)
|
||||||
|
}
|
||||||
|
|
||||||
// SMSCenterReader optionally supplies the SIM-configured service-centre
|
// SMSCenterReader optionally supplies the SIM-configured service-centre
|
||||||
// address needed for mobile-originated SMS over IMS.
|
// address needed for mobile-originated SMS over IMS.
|
||||||
type SMSCenterReader interface {
|
type SMSCenterReader interface {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ServerRegular } from "@fluentui/react-icons";
|
||||||
|
import type { DashboardHostInfo } from "../../types";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宿主机信息卡:CPU / 主板 / 内存 / 硬盘型号,后端一次性探测后缓存。
|
||||||
|
export function HostInfoCard({ info }: { info?: DashboardHostInfo | null }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ label: t("CPU 型号"), value: info?.cpuModel || "" },
|
||||||
|
{ label: t("主板型号"), value: info?.boardModel || "" },
|
||||||
|
{ label: t("内存型号"), value: info?.memoryModel || "" },
|
||||||
|
{ label: t("硬盘型号"), value: info?.diskModel || "" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<ServerRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("宿主机信息")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.label} className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="flex-shrink-0 text-xs text-gray-400">{row.label}</span>
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-right text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
title={row.value || undefined}
|
||||||
|
>
|
||||||
|
{row.value || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { ArrowDownRegular, ArrowUpRegular, GaugeRegular } from "@fluentui/react-icons";
|
||||||
|
import type { DashboardHostPerf } from "../../types";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
import { cx, formatBytes } from "../../lib/utils";
|
||||||
|
|
||||||
|
function percentText(value: number) {
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 利用率越高颜色越危险:<70 绿,<90 黄,其余红。
|
||||||
|
function barColor(percent: number) {
|
||||||
|
if (percent >= 90) return "bg-red-500";
|
||||||
|
if (percent >= 70) return "bg-amber-500";
|
||||||
|
return "bg-emerald-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function textColor(percent: number) {
|
||||||
|
if (percent >= 90) return "text-red-600 dark:text-red-400";
|
||||||
|
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
|
||||||
|
return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageBar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
|
||||||
|
const clamped = Math.min(100, Math.max(0, percent || 0));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-xs text-gray-400">{label}</span>
|
||||||
|
<span className="flex items-baseline gap-1.5">
|
||||||
|
{detail ? <span className="text-[10px] text-gray-400">{detail}</span> : null}
|
||||||
|
<span className={cx("text-xs font-bold tabular-nums", textColor(clamped))}>{percentText(clamped)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className={cx("h-full rounded-full transition-all duration-500", barColor(clamped))}
|
||||||
|
style={{ width: `${clamped}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 性能信息卡:CPU / 内存 / 硬盘进度条 + 实时网络上下行速率。
|
||||||
|
export function HostPerfCard({ perf }: { perf?: DashboardHostPerf | null }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<GaugeRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("性能信息")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<UsageBar label={t("CPU 使用率")} percent={perf?.cpuPercent ?? 0} />
|
||||||
|
<UsageBar
|
||||||
|
label={t("内存使用率")}
|
||||||
|
percent={perf?.memoryPercent ?? 0}
|
||||||
|
detail={perf && perf.memoryTotalBytes > 0 ? `${formatBytes(perf.memoryUsedBytes)} / ${formatBytes(perf.memoryTotalBytes)}` : undefined}
|
||||||
|
/>
|
||||||
|
<UsageBar
|
||||||
|
label={t("硬盘使用率")}
|
||||||
|
percent={perf?.diskPercent ?? 0}
|
||||||
|
detail={perf && perf.diskTotalBytes > 0 ? `${formatBytes(perf.diskUsedBytes)} / ${formatBytes(perf.diskTotalBytes)}` : undefined}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-0.5">
|
||||||
|
<span className="text-xs text-gray-400">{t("网络")}</span>
|
||||||
|
<span className="flex items-center gap-3 text-xs font-semibold tabular-nums">
|
||||||
|
<span className="flex items-center gap-1 text-sky-600 dark:text-sky-400" title={t("实时上传速率")}>
|
||||||
|
<ArrowUpRegular className="h-3.5 w-3.5" />
|
||||||
|
{formatBytes(perf?.netTxBps ?? 0)}/s
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400" title={t("实时下载速率")}>
|
||||||
|
<ArrowDownRegular className="h-3.5 w-3.5" />
|
||||||
|
{formatBytes(perf?.netRxBps ?? 0)}/s
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
||||||
|
import { useI18n, tf } from "../../lib/i18n";
|
||||||
|
import { cx } from "../../lib/utils";
|
||||||
|
|
||||||
|
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
|
||||||
|
type RateLevel = "green" | "yellow" | "orange" | "red";
|
||||||
|
|
||||||
|
function rateLevel(percent: number): RateLevel {
|
||||||
|
if (percent >= 100) return "green";
|
||||||
|
if (percent >= 80) return "yellow";
|
||||||
|
if (percent >= 50) return "orange";
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
|
||||||
|
green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" },
|
||||||
|
yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" },
|
||||||
|
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
|
||||||
|
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。
|
||||||
|
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const percent = total > 0 ? Math.round((online / total) * 100) : null;
|
||||||
|
const level = percent === null ? null : rateLevel(percent);
|
||||||
|
const styles = level ? LEVEL_STYLES[level] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center py-1">
|
||||||
|
{percent === null ? (
|
||||||
|
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</div>
|
||||||
|
) : (
|
||||||
|
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
|
||||||
|
{percent}
|
||||||
|
<span className="text-2xl">%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{styles ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
|
||||||
|
{t(styles.labelKey)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CalendarClockRegular } from "@fluentui/react-icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { DashboardUpcomingTask } from "../../types";
|
||||||
|
import { useI18n } from "../../lib/i18n";
|
||||||
|
|
||||||
|
function formatRunAt(value: string): string {
|
||||||
|
if (!value || value.startsWith("0001-")) return "--";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "--";
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将要执行的定时任务卡:按 nextRunAt 升序取前 3 条已启用任务。
|
||||||
|
export function UpcomingTasksCard({ tasks }: { tasks: DashboardUpcomingTask[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="ui-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarClockRegular className="h-4 w-4 text-sky-500" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("将要执行的定时任务")}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/automatic-tasks")}
|
||||||
|
className="text-xs font-medium text-sky-600 transition-colors hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||||
|
>
|
||||||
|
{t("查看全部")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="flex h-[4.5rem] items-center justify-center text-xs text-gray-400">
|
||||||
|
{t("暂无定时任务")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-white/5">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300" title={task.name}>
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 font-mono text-xs tabular-nums text-gray-500 dark:text-gray-400">
|
||||||
|
{formatRunAt(task.nextRunAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||||
|
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
||||||
|
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||||
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
|
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
|
||||||
|
|||||||
@@ -291,6 +291,24 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
重试: "Retry",
|
重试: "Retry",
|
||||||
暂无设备接入: "No devices connected",
|
暂无设备接入: "No devices connected",
|
||||||
请先在设备管理中添加或接管设备: "Add or adopt a device on the Devices page first",
|
请先在设备管理中添加或接管设备: "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",
|
管理网关参数与运行信息: "Manage gateway parameters and runtime info",
|
||||||
@@ -780,6 +798,8 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"改动将在此卡激活后生效": "Changes take effect once this card is activated",
|
"改动将在此卡激活后生效": "Changes take effect once this card is activated",
|
||||||
"数据未开启": "Data is off",
|
"数据未开启": "Data is off",
|
||||||
"数据平面": "Data Plane",
|
"数据平面": "Data Plane",
|
||||||
|
"运营商配置": "Carrier Profile",
|
||||||
|
"匹配依据": "Profile Match",
|
||||||
"方向": "Direction",
|
"方向": "Direction",
|
||||||
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
||||||
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { DashboardDevice } from "../types";
|
import type { DashboardDevice, DashboardHost, DashboardUpcomingTask } from "../types";
|
||||||
import { usePolling } from "../lib/usePolling";
|
import { usePolling } from "../lib/usePolling";
|
||||||
import { useI18n } from "../lib/i18n";
|
import { useI18n } from "../lib/i18n";
|
||||||
import { PageHeader } from "../components/ui/PageHeader";
|
import { PageHeader } from "../components/ui/PageHeader";
|
||||||
@@ -10,9 +10,17 @@ import { ErrorState } from "../components/ui/ErrorState";
|
|||||||
import { ListSkeleton } from "../components/ui/ListSkeleton";
|
import { ListSkeleton } from "../components/ui/ListSkeleton";
|
||||||
import { EmptyState } from "../components/ui/EmptyState";
|
import { EmptyState } from "../components/ui/EmptyState";
|
||||||
import { DeviceCard } from "../components/DeviceCard";
|
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 }
|
interface LoadError { message: string; status?: number; method?: string; url?: string }
|
||||||
|
|
||||||
|
// 任务卡只展示最近的三条;定时任务变化慢,轮询间隔比设备/性能数据更宽。
|
||||||
|
const UPCOMING_TASK_COUNT = 3;
|
||||||
|
const TASKS_POLL_INTERVAL = 15000;
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -20,7 +28,8 @@ export default function DashboardPage() {
|
|||||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||||
const [devicesError, setDevicesError] = useState<LoadError | null>(null);
|
const [devicesError, setDevicesError] = useState<LoadError | null>(null);
|
||||||
const [devicesOkAt, setDevicesOkAt] = useState<number | 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 () => {
|
const fetchDevices = useCallback(async () => {
|
||||||
setDevicesLoading(true);
|
setDevicesLoading(true);
|
||||||
@@ -28,9 +37,7 @@ export default function DashboardPage() {
|
|||||||
const list = await api<DashboardDevice[]>("/dashboard/devices");
|
const list = await api<DashboardDevice[]>("/dashboard/devices");
|
||||||
setDevices(list || []);
|
setDevices(list || []);
|
||||||
setDevicesError(null);
|
setDevicesError(null);
|
||||||
const now = Date.now();
|
setDevicesOkAt(Date.now());
|
||||||
setDevicesOkAt(now);
|
|
||||||
setLastRefresh(now);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setDevicesError({ message: e?.message || t("加载失败"), status: e?.status });
|
setDevicesError({ message: e?.message || t("加载失败"), status: e?.status });
|
||||||
} finally {
|
} 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(fetchDevices, 5000);
|
||||||
|
usePolling(fetchHost, 2000);
|
||||||
|
usePolling(fetchUpcomingTasks, TASKS_POLL_INTERVAL);
|
||||||
|
|
||||||
const total = devices.length;
|
const total = devices.length;
|
||||||
const online = devices.filter((d) => d?.healthy).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`);
|
const openDevice = (id: string) => navigate(`/devices?device=${encodeURIComponent(id)}&tab=overview`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,11 +82,11 @@ export default function DashboardPage() {
|
|||||||
subtitle={t("实时监测模组检测状态与出口连通性")}
|
subtitle={t("实时监测模组检测状态与出口连通性")}
|
||||||
actions={<RefreshButton loading={devicesLoading} onClick={fetchDevices} />}
|
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="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl: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>
|
<HostInfoCard info={host?.host} />
|
||||||
<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>
|
<HostPerfCard perf={host?.perf} />
|
||||||
<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>
|
<UpcomingTasksCard tasks={upcomingTasks} />
|
||||||
<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>
|
<OnlineRateCard online={online} total={total} />
|
||||||
</div>
|
</div>
|
||||||
{devicesError ? (
|
{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} />
|
<ErrorState className="mb-6" title={t("设备列表加载失败")} message={devicesError.message} statusCode={devicesError.status} requestMethod={devicesError.method} requestUrl={devicesError.url} lastSuccessAt={devicesOkAt} retryText={t("重试")} onRetry={fetchDevices} />
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ export default function DevicesPage() {
|
|||||||
modemImei: d.imei || "",
|
modemImei: d.imei || "",
|
||||||
usbPath: d.usbPath || "",
|
usbPath: d.usbPath || "",
|
||||||
deviceBackend: backend,
|
deviceBackend: backend,
|
||||||
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
|
deviceType: d.deviceType || (isReader ? "usb_sim_reader" : prev.deviceType),
|
||||||
esimTransport: isReader ? "pcsc" : backend,
|
esimTransport: isReader ? "pcsc" : backend,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface VoWiFiRuntime {
|
|||||||
phase: string;
|
phase: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
carrierProfile?: string;
|
||||||
|
carrierProfileFrom?: string;
|
||||||
dataplaneMode: string;
|
dataplaneMode: string;
|
||||||
iccid: string;
|
iccid: string;
|
||||||
imsi: string;
|
imsi: string;
|
||||||
@@ -142,6 +144,38 @@ export interface DashboardDevice {
|
|||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardHostInfo {
|
||||||
|
cpuModel: string;
|
||||||
|
boardModel: string;
|
||||||
|
memoryModel: string;
|
||||||
|
diskModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardHostPerf {
|
||||||
|
cpuPercent: number;
|
||||||
|
memoryPercent: number;
|
||||||
|
memoryUsedBytes: number;
|
||||||
|
memoryTotalBytes: number;
|
||||||
|
diskPercent: number;
|
||||||
|
diskUsedBytes: number;
|
||||||
|
diskTotalBytes: number;
|
||||||
|
netRxBps: number;
|
||||||
|
netTxBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardHost {
|
||||||
|
host: DashboardHostInfo;
|
||||||
|
perf: DashboardHostPerf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仪表盘定时任务卡只关心名字与下次执行时间。
|
||||||
|
export interface DashboardUpcomingTask {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
nextRunAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceOverview extends DeviceListItem {
|
export interface DeviceOverview extends DeviceListItem {
|
||||||
atPort?: string;
|
atPort?: string;
|
||||||
audioDevice?: string;
|
audioDevice?: string;
|
||||||
@@ -172,6 +206,7 @@ export interface DeviceStatus {
|
|||||||
export interface DiscoveredDevice {
|
export interface DiscoveredDevice {
|
||||||
hardwareKind?: string;
|
hardwareKind?: string;
|
||||||
readerName?: string;
|
readerName?: string;
|
||||||
|
deviceType?: DeviceType;
|
||||||
discoveryKey: string;
|
discoveryKey: string;
|
||||||
controlPath: string;
|
controlPath: string;
|
||||||
netInterface: string;
|
netInterface: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user