Compare commits

..
7 Commits
Author SHA1 Message Date
geekouc 9327f9a7cb feat: add per-card cellular IMS SMS policy 2026-08-22 00:16:23 +08:00
Meng MengandGitHub b2daa972d2 fix(ims): omit empty PANI call headers (#76)
* fix(ims): omit empty PANI call headers

* test(ims): exercise disabled PANI resolution
2026-08-21 23:15:58 +08:00
ihipopandGitHub 54288e5657 fix(ims): align VoWiFi SIP profile behavior (#75) 2026-08-21 22:48:55 +08:00
76af0784e1 fix: stabilize OpenStick 410 VoWiFi startup (#74)
* fix: stabilize OpenStick 410 VoWiFi startup

* fix: recover OpenStick 410 eUICC channel allocation

---------

Co-authored-by: MengMengCode <[email protected]>
2026-08-21 19:26:37 +08:00
06ea65558c fix: ignore non-voice CLCC records in call monitor (#71)
Co-authored-by: geekouc <[email protected]>
2026-08-21 15:44:00 +08:00
MengMengCode d26937f9eb Fix something 2026-08-21 12:25:16 +08:00
MengMengCode 688e1e8311 feat(logging): implement log retention policy with hard limit and exclusion filters
- Added MaxLogEvents constant to enforce a hard limit on stored log events.
- Updated AppendLogEvent to discard older logs when the limit is exceeded.
- Enhanced ListLogEvents to support filtering by log level and excluding specific messages.
- Introduced ClearLogEvents method to permanently remove logs and prevent re-queuing of cleared entries.
- Modified LogRetentionCard component to reflect the new log retention settings and limits.
- Added logging categories for better organization and filtering in the UI.
- Implemented sanitization for sensitive information in logs.
- Added tests for log event limits and clearing functionality.
2026-08-21 01:01:41 +08:00
56 changed files with 2572 additions and 245 deletions
+147 -29
View File
@@ -41,7 +41,7 @@ import (
)
func main() {
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
logs := loghub.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 2000)
logger := slog.New(logs)
args := os.Args[1:]
@@ -206,7 +206,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
}
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
deviceLogger := logger.With("category", "hardware")
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: deviceLogger})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -227,7 +228,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
}()
pollContext, cancelPolling := context.WithCancel(context.Background())
defer cancelPolling()
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
go pollDeviceSnapshots(pollContext, deviceLogger, database, deviceManager)
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
go collectCellularTraffic(pollContext, logger, database)
go persistLogsToStore(pollContext, logger, logs, database)
@@ -495,26 +496,7 @@ func restoreConfiguredCellularData(
if err != nil {
continue
}
networkRequest := device.NetworkRequest{
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
}
if entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
networkRequest.APN = policy.APN
if policy.IPVersion != "" {
networkRequest.IPVersion = policy.IPVersion
}
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
networkRequest.Username = profile.Username
networkRequest.Password = profile.Password
networkRequest.Authentication = profile.AuthType
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
networkRequest.IPVersion = profile.RoamingIPVersion
}
}
}
}
networkRequest := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
cancel()
@@ -526,6 +508,40 @@ func restoreConfiguredCellularData(
}
}
func configuredCellularNetworkRequest(
ctx context.Context,
database *store.Store,
config store.Device,
snapshot *device.Snapshot,
) device.NetworkRequest {
request := device.NetworkRequest{
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
}
if snapshot == nil {
return request
}
iccid := strings.TrimSpace(snapshot.ICCID)
policy, err := database.CardPolicy(ctx, iccid)
if err != nil {
return request
}
request.APN = policy.APN
if policy.IPVersion != "" {
request.IPVersion = policy.IPVersion
}
profile, err := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion)
if err != nil {
return request
}
request.Username = profile.Username
request.Password = profile.Password
request.Authentication = profile.AuthType
if snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
request.IPVersion = profile.RoamingIPVersion
}
return request
}
func disableAllDeveloperCellularData(
ctx context.Context,
logger *slog.Logger,
@@ -640,7 +656,7 @@ func configureVoWiFiRuntime(
Devices: mapper,
}
manager := vowifiruntime.New(vowifiruntime.Options{
Logger: logger,
Logger: logger.With("category", "vowifi"),
OnState: projector.Save,
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
deviceConfig, err := database.Device(factoryContext, deviceID)
@@ -683,7 +699,18 @@ func configureVoWiFiRuntime(
)
}
}
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
requestEnable := func() error {
_, requestErr := manager.RequestEnabled(deviceConfig.ID, true)
return requestErr
}
if err := requestVoWiFiStartup(
ctx,
logger,
deviceConfig.DeviceType,
deviceConfig.ID,
wifi410VoWiFiStartupDelay,
requestEnable,
); err != nil {
_ = manager.Close(context.Background())
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
}
@@ -695,8 +722,55 @@ func configureVoWiFiRuntime(
const (
vowifiStartupRadioAttempts = 3
vowifiStartupRadioDelay = time.Second
wifi410VoWiFiStartupDelay = 80 * time.Second
)
// requestVoWiFiStartup delays only the persisted startup policy for OpenStick
// 410 devices. Their Qualcomm UIM and Vodafone ePDG path need a short quiet
// period after a cold boot; user-triggered reconnects and every other device
// type continue to execute immediately.
func requestVoWiFiStartup(
ctx context.Context,
logger *slog.Logger,
deviceType string,
deviceID string,
delay time.Duration,
request func() error,
) error {
if deviceType != store.DeviceTypeWiFi410 || delay <= 0 {
return request()
}
if logger == nil {
logger = slog.Default()
}
logger.Info(
"OpenStick 410 VoWiFi startup delayed",
"device_id", deviceID,
"delay", delay,
)
go func() {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
if err := request(); err != nil {
logger.Warn(
"OpenStick 410 delayed VoWiFi startup failed",
"device_id", deviceID,
"error", err,
)
}
}()
return nil
}
func shouldDelayWiFi410VoWiFi(deviceType string, now, notBefore time.Time) bool {
return deviceType == store.DeviceTypeWiFi410 && now.Before(notBefore)
}
type flightModeSetter interface {
SetFlight(context.Context, string, bool) (device.FlightResult, error)
}
@@ -717,7 +791,7 @@ func protectVoWiFiStartupRadioWithRetry(
physicalID string,
attempts int,
delay time.Duration,
) error {
) error {
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
@@ -759,14 +833,15 @@ func newVoWiFiOrchestrator(
if apn == "" {
apn = "ims"
}
vowifiLogger := logger.With("category", "vowifi", "device_id", deviceConfig.ID)
tunnelProvider, err := ike.NewProvider(ike.Config{
APN: apn, Logger: logger, AutoProposalFallback: true,
APN: apn, Logger: vowifiLogger, AutoProposalFallback: true,
})
if err != nil {
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
}
imsProvider, err := ims.NewProvider(adapter, ims.Config{
Logger: logger,
Logger: vowifiLogger,
// Carrier-specific transport and SMSC defaults live in the shared data
// profile. Prefer network-provided P-CSCF hints, then safely try the
// alternate transport only if no SIP response was observed.
@@ -983,6 +1058,10 @@ func persistLogsToStore(
if !ok {
return
}
if loghub.IsHTTPAccessEntry(entry) {
continue
}
entry = loghub.SanitizeEntry(entry)
var fields json.RawMessage
if len(entry.Fields) > 0 {
if raw, err := json.Marshal(entry.Fields); err == nil {
@@ -1097,7 +1176,7 @@ func enforceDefaultSafeCardPolicy(
}
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
}); err != nil {
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
return
@@ -1130,6 +1209,10 @@ func reconcileCardPolicies(
vowifiManager *vowifiruntime.Manager,
) {
observedCards := make(map[string]string)
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
imsApplied := make(map[string]string)
imsRetryAfter := make(map[string]time.Time)
imsDataRestorePending := make(map[string]bool)
reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil {
@@ -1176,6 +1259,38 @@ func reconcileCardPolicies(
if policyErr != nil {
continue
}
imsKey := fmt.Sprintf("%s:%t", iccid, policy.CellularIMSEnabled)
if policy.CellularIMSManaged && imsApplied[config.ID] != imsKey && !time.Now().Before(imsRetryAfter[config.ID]) {
imsContext, cancelIMS := context.WithTimeout(ctx, 45*time.Second)
status, imsErr := manager.SetCellularIMS(imsContext, entry.ID, policy.CellularIMSEnabled)
cancelIMS()
if imsErr != nil {
imsRetryAfter[config.ID] = time.Now().Add(time.Minute)
logger.Warn("reconcile cellular IMS policy failed", "device_id", config.ID, "iccid", iccid, "error", imsErr)
} else {
imsApplied[config.ID] = imsKey
delete(imsRetryAfter, config.ID)
logger.Info("reconciled cellular IMS policy", "device_id", config.ID, "iccid", iccid,
"enabled", policy.CellularIMSEnabled, "registered", status.Registered,
"changed", status.Changed, "rebooting", status.Rebooting)
if status.Rebooting {
imsDataRestorePending[config.ID] = config.NetworkEnabled && !config.VoWiFiEnabled
continue
}
}
}
if imsDataRestorePending[config.ID] && config.NetworkEnabled && !policy.VoWiFiEnabled && entry.Snapshot.PSAttached {
request := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
restoreContext, cancelRestore := context.WithTimeout(ctx, 60*time.Second)
_, restoreErr := manager.SetNetwork(restoreContext, entry.ID, request)
cancelRestore()
if restoreErr != nil {
logger.Warn("reconcile cellular data after IMS reboot failed", "device_id", config.ID, "error", restoreErr)
continue
}
delete(imsDataRestorePending, config.ID)
logger.Info("restored cellular data after reconciled IMS reboot", "device_id", config.ID, "interface", config.Interface)
}
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
@@ -1211,6 +1326,9 @@ func reconcileCardPolicies(
}
switch {
case stateErr != nil || !state.Enabled:
if shouldDelayWiFi410VoWiFi(config.DeviceType, time.Now(), wifi410StartupNotBefore) {
continue
}
_, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID)
+158
View File
@@ -0,0 +1,158 @@
package device
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"vocat/internal/modem"
)
// CellularIMSStatus is the Quectel baseband IMS switch and current registration
// state. Configured is persistent module configuration; Registered is live and
// may remain false until the operator finishes IMS registration.
type CellularIMSStatus struct {
Supported bool `json:"supported"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
CSKnown bool `json:"csKnown"`
CSRegistered bool `json:"csRegistered"`
Changed bool `json:"changed,omitempty"`
Rebooting bool `json:"rebooting,omitempty"`
}
var cellularIMSLine = regexp.MustCompile(`(?i)^\+QCFG:\s*"ims"\s*,\s*([01])(?:\s*,\s*([01]))?\s*$`)
var cellularCSLine = regexp.MustCompile(`(?i)^\+CREG:\s*(?:\d+\s*,\s*)?([0-9]+)(?:\s*,.*)?$`)
func parseCellularCSRegistration(lines []string) (registered, known bool) {
for _, line := range lines {
matches := cellularCSLine.FindStringSubmatch(strings.TrimSpace(line))
if matches == nil {
continue
}
status, err := strconv.Atoi(matches[1])
if err != nil {
continue
}
return status == 1 || status == 5, true
}
return false, false
}
func parseCellularIMSStatus(lines []string) (CellularIMSStatus, error) {
for _, line := range lines {
matches := cellularIMSLine.FindStringSubmatch(strings.TrimSpace(line))
if matches == nil {
continue
}
configured, _ := strconv.Atoi(matches[1])
registered := 0
if len(matches) > 2 && matches[2] != "" {
registered, _ = strconv.Atoi(matches[2])
}
return CellularIMSStatus{
Supported: true, Configured: configured == 1, Registered: registered == 1,
}, nil
}
return CellularIMSStatus{}, errors.New("modem did not return a Quectel IMS status")
}
func (manager *Manager) CellularIMS(ctx context.Context, id string) (CellularIMSStatus, error) {
state, err := manager.lookup(id)
if err != nil {
return CellularIMSStatus{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return CellularIMSStatus{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status, err := manager.readCellularIMS(ctx, client)
if err == nil {
if response, csErr := manager.command(ctx, client, "AT+CREG?"); csErr == nil {
status.CSRegistered, status.CSKnown = parseCellularCSRegistration(response.Lines)
}
}
manager.setResult(id, state, nil, err)
return status, err
}
// SetCellularIMS changes the persistent Quectel IMS override. A full modem
// restart is issued only when the configured value changes; this is essential
// because QCFG may report the new setting before the baseband has loaded it.
func (manager *Manager) SetCellularIMS(ctx context.Context, id string, enabled bool) (CellularIMSStatus, error) {
state, err := manager.lookup(id)
if err != nil {
return CellularIMSStatus{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return CellularIMSStatus{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status, err := manager.readCellularIMS(ctx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
if status.Configured == enabled {
manager.setResult(id, state, nil, nil)
return status, nil
}
target := 0
if enabled {
target = 1
}
if _, err = manager.command(ctx, client, fmt.Sprintf(`AT+QCFG="ims",%d`, target)); err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status, err = manager.readCellularIMS(ctx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
if status.Configured != enabled {
err = errors.New("modem did not retain the requested IMS setting")
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status.Changed = true
status.Rebooting = true
rebootCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
_, err = client.Execute(rebootCtx, "AT+CFUN=1,1")
cancel()
if closeErr := client.Close(); err == nil {
err = closeErr
}
state.client = nil
state.preFlightMode = nil
manager.clearSnapshot(id, state)
manager.setResult(id, state, nil, err)
return status, err
}
func (manager *Manager) readCellularIMS(ctx context.Context, client modem.Client) (CellularIMSStatus, error) {
response, err := manager.command(ctx, client, `AT+QCFG="ims"`)
if err != nil {
return CellularIMSStatus{}, fmt.Errorf("query cellular IMS: %w", err)
}
status, err := parseCellularIMSStatus(response.Lines)
if err != nil {
return CellularIMSStatus{}, err
}
return status, nil
}
+80
View File
@@ -0,0 +1,80 @@
package device
import (
"context"
"testing"
"vocat/internal/modem"
)
func TestParseCellularIMSStatus(t *testing.T) {
for _, test := range []struct {
line string
configured, active bool
}{
{`+QCFG: "ims",0,0`, false, false},
{`+QCFG: "ims",1,0`, true, false},
{`+QCFG: "ims", 1, 1`, true, true},
{`+QCFG: "ims",1`, true, false},
} {
status, err := parseCellularIMSStatus([]string{test.line})
if err != nil || !status.Supported || status.Configured != test.configured || status.Registered != test.active {
t.Errorf("parseCellularIMSStatus(%q) = %+v, %v", test.line, status, err)
}
}
if _, err := parseCellularIMSStatus([]string{"OK"}); err == nil {
t.Fatal("missing QCFG status was accepted")
}
}
func TestParseCellularCSRegistration(t *testing.T) {
for _, test := range []struct {
line string
registered bool
}{
{`+CREG: 0,1`, true},
{`+CREG: 2,5,"1234","12345678",7`, true},
{`+CREG: 0,3`, false},
} {
registered, known := parseCellularCSRegistration([]string{test.line})
if !known || registered != test.registered {
t.Errorf("parseCellularCSRegistration(%q) = %t, %t", test.line, registered, known)
}
}
if _, known := parseCellularCSRegistration([]string{"OK"}); known {
t.Fatal("missing CREG status was accepted")
}
}
func TestSetCellularIMSEnablesAndRebootsOnlyOnce(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",0,0`}, Final: "OK"}},
{command: `AT+QCFG="ims",1`, response: modem.Response{Final: "OK"}},
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",1,0`}, Final: "OK"}},
{command: "AT+CFUN=1,1", response: modem.Response{Final: "OK"}},
}}
manager, id := newStartedTestManager(t, client)
status, err := manager.SetCellularIMS(context.Background(), id, true)
if err != nil || !status.Configured || !status.Changed || !status.Rebooting {
t.Fatalf("SetCellularIMS = %+v, %v", status, err)
}
if client.closeCount != 1 {
t.Fatalf("close count = %d, want 1", client.closeCount)
}
client.assertDone(t)
}
func TestSetCellularIMSNoopDoesNotReboot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",1,1`}, Final: "OK"}},
}}
manager, id := newStartedTestManager(t, client)
status, err := manager.SetCellularIMS(context.Background(), id, true)
if err != nil || status.Changed || status.Rebooting || !status.Registered {
t.Fatalf("SetCellularIMS = %+v, %v", status, err)
}
if client.closeCount != 0 {
t.Fatalf("close count = %d, want 0", client.closeCount)
}
client.assertDone(t)
}
+3
View File
@@ -439,6 +439,9 @@ func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, cand
}
const slot uint8 = 1
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid)
if recoverySession, recoveryOK := session.(nativeQMIChannelRecoverySession); recoveryOK && isQMIInsufficientResources(err) {
logicalChannel, err = openNativeQMIChannelWithRecovery(openContext, recoverySession, slot, aid)
}
if err != nil {
_ = session.Close()
return nil, fmt.Errorf("%w: %v", errNoEUICC, err)
+54
View File
@@ -206,6 +206,11 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
}
seen := make(map[string]struct{}, len(candidates))
type discoveryEvent struct {
connected bool
candidate modem.Candidate
}
events := make([]discoveryEvent, 0)
manager.mu.Lock()
for _, candidate := range candidates {
if strings.TrimSpace(candidate.ID) == "" {
@@ -218,8 +223,12 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
candidate: candidate,
discovered: true,
}
events = append(events, discoveryEvent{connected: true, candidate: candidate})
continue
}
if !state.discovered {
events = append(events, discoveryEvent{connected: true, candidate: candidate})
}
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
state.resetClientOnLock = true
}
@@ -231,10 +240,28 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if _, ok := seen[id]; ok {
continue
}
if state.discovered {
events = append(events, discoveryEvent{candidate: state.candidate})
}
state.discovered = false
stale = append(stale, state)
}
manager.mu.Unlock()
if manager.logger != nil {
for _, event := range events {
message := "hardware disconnected"
if event.connected {
message = "hardware connected"
}
manager.logger.Info(message,
"event", "hardware.discovery",
"device_id", event.candidate.ID,
"hardware_kind", event.candidate.HardwareKind,
"vendor_id", event.candidate.VendorID,
"product_id", event.candidate.ProductID,
)
}
}
for _, state := range stale {
state.opMu.Lock()
@@ -382,6 +409,11 @@ func (manager *Manager) setResult(
return
}
previousError := state.lastError
var previousSnapshot *Snapshot
if state.snapshot != nil {
value := *state.snapshot
previousSnapshot = &value
}
if snapshot != nil {
value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...)
@@ -394,6 +426,13 @@ func (manager *Manager) setResult(
state.lastError = ""
}
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
registrationChanged := snapshot != nil && manager.logger != nil &&
(previousSnapshot == nil ||
previousSnapshot.RegistrationStatus != snapshot.RegistrationStatus ||
previousSnapshot.OperatorCode != snapshot.OperatorCode ||
previousSnapshot.AccessTech != snapshot.AccessTech ||
previousSnapshot.PSAttached != snapshot.PSAttached ||
previousSnapshot.SIMStatus != snapshot.SIMStatus)
backend := state.backend
hardwareKind := state.candidate.HardwareKind
manager.mu.Unlock()
@@ -406,6 +445,21 @@ func (manager *Manager) setResult(
"error", HardwareErrorDetail(err),
)
}
if registrationChanged {
manager.logger.Info(
"cellular registration state changed",
"category", "network",
"event", "network.registration",
"device_id", id,
"sim_status", snapshot.SIMStatus,
"registration_status", snapshot.RegistrationStatus,
"registration_source", snapshot.RegistrationSource,
"operator", snapshot.OperatorName,
"operator_code", snapshot.OperatorCode,
"access_technology", snapshot.AccessTech,
"packet_service_attached", snapshot.PSAttached,
)
}
}
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
+57 -1
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"time"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
)
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error {
@@ -70,7 +72,7 @@ func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, prefe
func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
channel, openErr := session.OpenLogicalChannel(ctx, 1, aid)
channel, openErr := openNativeQMIChannelWithRecovery(ctx, session, 1, aid)
if openErr != nil {
return fmt.Errorf("open QMI UIM logical channel: %w", openErr)
}
@@ -102,6 +104,60 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai
return
}
type nativeQMIChannelRecoverySession interface {
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
PowerOffSIM(context.Context, uint8) error
PowerOnSIM(context.Context, uint8) error
}
// OpenStick 410 can leave the physical UICC powered but unable to allocate a
// logical channel after a SIM hot-swap. A UIM service reset alone does not
// clear that state; cycling the affected physical slot does. Recover only the
// precise QMI InsufficientResources response, then retry the original AID once.
func openNativeQMIChannelWithRecovery(
ctx context.Context,
session nativeQMIChannelRecoverySession,
slot uint8,
aid []byte,
) (byte, error) {
channel, err := session.OpenLogicalChannel(ctx, slot, aid)
if err == nil || !isQMIInsufficientResources(err) {
return channel, err
}
if resetter, ok := session.(nativeQMIUIMResetSession); ok {
_ = resetter.ResetUIM(ctx)
}
if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
if powerErr := session.PowerOnSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
return session.OpenLogicalChannel(ctx, slot, aid)
}
func isQMIInsufficientResources(err error) bool {
qmiErr := qmi.GetQMIError(err)
return qmiErr != nil && qmiErr.Service == qmi.ServiceUIM && qmiErr.ErrorCode == 0x0044
}
var waitNativeQMIRecovery = func(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
qmiMode, modeErr := session.GetOperatingMode(ctx)
+23 -3
View File
@@ -9,9 +9,9 @@ import (
"time"
)
// Entry is the stable, secret-neutral representation exposed by the log API.
// Callers remain responsible for never adding credentials or keying material
// to slog attributes.
// Entry is the stable, centrally-redacted representation exposed by the log
// API. The Hub sanitizes both the downstream handler and the captured entry so
// diagnostic logs can be safely exported by users.
type Entry struct {
Time time.Time `json:"time"`
Level string `json:"level"`
@@ -58,6 +58,7 @@ func (h *Hub) Enabled(ctx context.Context, level slog.Level) bool {
}
func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
record = sanitizeRecord(record)
err := h.next.Handle(ctx, record)
fields := make(map[string]any)
for _, attr := range h.attrs {
@@ -81,6 +82,7 @@ func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
}
func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
attrs = sanitizeAttrs(attrs)
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
return &Hub{
next: h.next.WithAttrs(attrs),
@@ -180,6 +182,24 @@ func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) {
return channel, cancel
}
// Clear drops captured history and every entry currently queued for live and
// persistence subscribers. Subscribers stay connected for future events.
func (h *Hub) Clear() {
h.core.mu.Lock()
h.core.entries = h.core.entries[:0]
for _, subscriber := range h.core.subscribers {
for {
select {
case <-subscriber:
continue
default:
}
break
}
}
h.core.mu.Unlock()
}
func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
+74
View File
@@ -1,9 +1,12 @@
package loghub
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
)
@@ -29,6 +32,51 @@ func TestHubHistoryFiltersAndBounds(t *testing.T) {
}
}
func TestHubRedactsDownstreamAndHistoryAndPreservesErrors(t *testing.T) {
var output bytes.Buffer
hub := New(slog.NewJSONHandler(&output, nil), 100)
logger := slog.New(hub).With("imsi", "234159611634973")
logger.Warn(
"delivery to +447700900123 failed",
"iccid", "8944101234567890123",
"peer", "+447700900456",
"error", errors.New("modem rejected MSISDN=447700900789 with +CMS ERROR: 305"),
)
entry := hub.History(1, slog.LevelDebug, "")[0]
if strings.Contains(entry.Message, "447700900123") {
t.Fatalf("message was not redacted: %q", entry.Message)
}
for _, key := range []string{"imsi", "iccid", "peer"} {
if value := entry.Fields[key]; !strings.Contains(value.(string), "REDACTED") {
t.Fatalf("%s = %#v, want redacted", key, value)
}
}
errorText, ok := entry.Fields["error"].(string)
if !ok || !strings.Contains(errorText, "+CMS ERROR: 305") || strings.Contains(errorText, "447700900789") {
t.Fatalf("error = %#v, want original modem error with identity redacted", entry.Fields["error"])
}
if raw := output.String(); strings.Contains(raw, "234159611634973") || strings.Contains(raw, "447700900") {
t.Fatalf("downstream output leaked an identity: %s", raw)
}
}
func TestSanitizeEntryProtectsLegacyNestedFields(t *testing.T) {
entry := SanitizeEntry(Entry{
Message: "incoming SIP from sip:[email protected]",
Fields: map[string]any{
"details": map[string]any{"associated_number": "+447700900456", "status": "registered"},
},
})
if strings.Contains(entry.Message, "447700900123") {
t.Fatalf("message = %q", entry.Message)
}
details := entry.Fields["details"].(map[string]any)
if strings.Contains(details["associated_number"].(string), "447700900456") {
t.Fatalf("nested field leaked: %#v", details)
}
}
func TestHubSubscription(t *testing.T) {
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
entries, cancel := hub.Subscribe(1)
@@ -47,3 +95,29 @@ func TestHubSubscription(t *testing.T) {
t.Fatal("timed out waiting for log entry")
}
}
func TestHubClearDropsHistoryAndQueuedEntries(t *testing.T) {
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
entries, cancel := hub.Subscribe(4)
defer cancel()
logger := slog.New(hub)
logger.Info("before clear")
hub.Clear()
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("history after Clear = %#v", history)
}
select {
case entry := <-entries:
t.Fatalf("queued entry survived Clear: %#v", entry)
default:
}
logger.Info("after clear")
select {
case entry := <-entries:
if entry.Message != "after clear" {
t.Fatalf("entry = %#v", entry)
}
case <-time.After(time.Second):
t.Fatal("subscriber did not remain active after Clear")
}
}
+215
View File
@@ -0,0 +1,215 @@
package loghub
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"regexp"
"strings"
"time"
"unicode"
)
var (
sipIdentityPattern = regexp.MustCompile(`(?i)\b(sips?|tel):([^@;>,\s]+)(@[^;>,\s]+)?`)
internationalPhonePattern = regexp.MustCompile(`(?:\+|00)[0-9][0-9 ()-]{5,}[0-9]`)
longDigitsPattern = regexp.MustCompile(`\b[0-9]{7,22}\b`)
labeledIdentityPattern = regexp.MustCompile(`(?i)\b(iccid|imsi|msisdn|imei|eid)\s*([=:])\s*([a-z0-9+_-]{7,})`)
)
// IsHTTPAccessEntry identifies legacy request-traffic entries. Access traffic
// is intentionally excluded from the user diagnostic log surface.
func IsHTTPAccessEntry(entry Entry) bool {
if strings.EqualFold(strings.TrimSpace(entry.Message), "http request") {
return true
}
category, _ := entry.Fields["category"].(string)
return strings.EqualFold(strings.TrimSpace(category), "http_access")
}
// SanitizeEntry also protects records that were persisted by an older build
// before central redaction was introduced.
func SanitizeEntry(entry Entry) Entry {
entry.Message = RedactString(entry.Message)
entry.Caller = RedactString(entry.Caller)
if entry.Fields != nil {
entry.Fields = sanitizeMap(entry.Fields)
}
return entry
}
// RedactString masks common telecom identities while retaining enough of the
// suffix to correlate repeated events in an exported diagnostic log.
func RedactString(value string) string {
if value == "" {
return value
}
value = labeledIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
parts := labeledIdentityPattern.FindStringSubmatch(match)
return parts[1] + parts[2] + maskToken(parts[3])
})
value = sipIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
parts := sipIdentityPattern.FindStringSubmatch(match)
domain := parts[3]
return parts[1] + ":" + maskToken(parts[2]) + domain
})
value = internationalPhonePattern.ReplaceAllStringFunc(value, maskToken)
return longDigitsPattern.ReplaceAllStringFunc(value, maskToken)
}
func sanitizeRecord(record slog.Record) slog.Record {
clean := slog.NewRecord(record.Time, record.Level, RedactString(record.Message), record.PC)
record.Attrs(func(attr slog.Attr) bool {
clean.AddAttrs(sanitizeAttr(attr))
return true
})
return clean
}
func sanitizeAttrs(attrs []slog.Attr) []slog.Attr {
clean := make([]slog.Attr, 0, len(attrs))
for _, attr := range attrs {
clean = append(clean, sanitizeAttr(attr))
}
return clean
}
func sanitizeAttr(attr slog.Attr) slog.Attr {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return attr
}
if sensitiveKey(attr.Key) {
return slog.String(attr.Key, maskToken(valueText(attr.Value.Any())))
}
if attr.Value.Kind() == slog.KindGroup {
children := attr.Value.Group()
return slog.Group(attr.Key, attrsToAny(sanitizeAttrs(children))...)
}
switch attr.Value.Kind() {
case slog.KindString:
return slog.String(attr.Key, RedactString(attr.Value.String()))
case slog.KindAny:
return slog.Any(attr.Key, sanitizeAny(attr.Value.Any(), attr.Key))
default:
return attr
}
}
func attrsToAny(attrs []slog.Attr) []any {
values := make([]any, len(attrs))
for index := range attrs {
values[index] = attrs[index]
}
return values
}
func sanitizeAny(value any, key string) any {
if value == nil {
return nil
}
if sensitiveKey(key) {
return maskToken(valueText(value))
}
switch typed := value.(type) {
case error:
return RedactString(typed.Error())
case string:
return RedactString(typed)
case []byte:
return RedactString(string(typed))
case json.RawMessage:
var decoded any
if json.Unmarshal(typed, &decoded) == nil {
return sanitizeAny(decoded, key)
}
return RedactString(string(typed))
case map[string]any:
return sanitizeMap(typed)
case []any:
result := make([]any, len(typed))
for index := range typed {
result[index] = sanitizeAny(typed[index], key)
}
return result
case time.Time, time.Duration:
return value
}
rv := reflect.ValueOf(value)
if rv.IsValid() && (rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct || rv.Kind() == reflect.Pointer) {
if raw, err := json.Marshal(value); err == nil {
var decoded any
if json.Unmarshal(raw, &decoded) == nil {
return sanitizeAny(decoded, key)
}
}
}
if stringer, ok := value.(fmt.Stringer); ok {
return RedactString(stringer.String())
}
return value
}
func sanitizeMap(source map[string]any) map[string]any {
result := make(map[string]any, len(source))
for key, value := range source {
result[key] = sanitizeAny(value, key)
}
return result
}
func sensitiveKey(key string) bool {
normalized := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return unicode.ToLower(r)
}
return -1
}, key)
if strings.Contains(normalized, "password") || strings.Contains(normalized, "passwd") ||
strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") ||
strings.Contains(normalized, "cookie") || strings.Contains(normalized, "authorization") ||
strings.Contains(normalized, "privateidentity") || strings.Contains(normalized, "publicidentity") ||
strings.Contains(normalized, "associatednumber") || strings.Contains(normalized, "sipuri") {
return true
}
switch normalized {
case "iccid", "imsi", "imei", "eid", "supi", "suci", "msisdn", "phone", "phonenumber",
"number", "caller", "called", "callee", "recipient", "peer", "from", "to":
return true
default:
return false
}
}
func valueText(value any) string {
if value == nil {
return ""
}
if err, ok := value.(error); ok {
return err.Error()
}
return fmt.Sprint(value)
}
func maskToken(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "[REDACTED]"
}
runes := []rune(value)
digits := make([]rune, 0, 4)
for index := len(runes) - 1; index >= 0 && len(digits) < 4; index-- {
if unicode.IsDigit(runes[index]) {
digits = append(digits, runes[index])
}
}
if len(digits) == 0 {
return "[REDACTED]"
}
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
digits[left], digits[right] = digits[right], digits[left]
}
return "[REDACTED:" + string(digits) + "]"
}
+33 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
@@ -21,6 +22,20 @@ func (s *Server) recordAudit(
outcome string,
remoteAddr string,
) {
level := slog.LevelInfo
if !strings.EqualFold(strings.TrimSpace(outcome), "success") {
level = slog.LevelWarn
}
if s.logger != nil {
s.logger.Log(ctx, level, "user operation",
"category", auditLogCategory(action),
"event", action,
"actor", actor,
"entity_type", entityType,
"entity_id", entityID,
"outcome", outcome,
)
}
if s.store == nil {
return
}
@@ -34,7 +49,24 @@ func (s *Server) recordAudit(
CreatedAt: time.Now().UTC(),
})
if err != nil {
s.logger.Warn("write audit event failed", "action", action, "error", err)
s.logger.Warn("write audit event failed", "category", "system", "action", action, "raw_error", err)
}
}
func auditLogCategory(action string) string {
action = strings.ToLower(strings.TrimSpace(action))
switch {
case strings.Contains(action, ".sms") || strings.HasPrefix(action, "sms."):
return "sms"
case strings.Contains(action, ".call") || strings.HasPrefix(action, "call."):
return "call"
case strings.Contains(action, "vowifi") || strings.Contains(action, "ims"):
return "vowifi"
case strings.Contains(action, "device") || strings.Contains(action, "esim") ||
strings.Contains(action, ".at.") || strings.Contains(action, ".ussd"):
return "hardware"
default:
return "operation"
}
}
+10
View File
@@ -130,6 +130,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
}
}
if err != nil {
s.logger.Warn("VoWiFi call operation failed",
"category", "call", "event", "call."+action,
"device_id", config.ID, "number", number, "call_id", callID,
"transport", transport, "raw_error", err,
)
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
return true
}
@@ -156,6 +161,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
return true
}
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
s.logger.Warn("cellular call operation rejected",
"category", "call", "event", "call."+action,
"device_id", config.ID, "number", number, "transport", transport,
"modem_final", response.Final, "raw_response", response.Text(),
)
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
return true
}
+23 -7
View File
@@ -98,11 +98,21 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
if notification.Time.IsZero() {
notification.Time = time.Now().UTC()
}
if s.logger != nil {
s.logger.Info("incoming call detected",
"category", "call",
"event", "call.incoming",
"device_id", notification.DeviceID,
"caller", notification.Caller,
"called", notification.Called,
"transport", notification.Environment,
)
}
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
if s.logger != nil {
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
s.logger.Debug("suppressed duplicate incoming call notification", "category", "call", "device_id", notification.DeviceID, "caller", notification.Caller)
}
return
}
@@ -137,7 +147,7 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
}
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
if s.logger != nil {
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
s.logger.Warn("send incoming call notification", "category", "call", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "raw_error", err)
}
}
}
@@ -315,11 +325,7 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
}
calls := parseCLCC(response)
for _, call := range calls {
direction, _ := call["direction"].(int)
state, _ := call["state"].(int)
// direction 1 = incoming (Mobile Terminated)
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
if isIncomingVoiceCLCC(call) {
caller, _ := call["number"].(string)
if caller == "" {
caller = "未知号码"
@@ -341,3 +347,13 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
}
}
}
func isIncomingVoiceCLCC(call map[string]any) bool {
direction, _ := call["direction"].(int)
state, _ := call["state"].(int)
mode, _ := call["mode"].(int)
// direction 1 = incoming (Mobile Terminated)
// mode 0 = voice; some modems also expose packet-data sessions as CLCC mode 1
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
return direction == 1 && mode == 0 && (state == 4 || state == 5 || state == 0 || state == 3)
}
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"vocat/internal/modem"
)
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
@@ -61,6 +63,56 @@ func TestIncomingCallDeduplication(t *testing.T) {
}
}
func TestIncomingVoiceCLCCIgnoresDataSessions(t *testing.T) {
tests := []struct {
name string
call map[string]any
want bool
}{
{
name: "incoming voice ringing",
call: map[string]any{"direction": 1, "state": 4, "mode": 0},
want: true,
},
{
name: "incoming voice active",
call: map[string]any{"direction": 1, "state": 0, "mode": 0},
want: true,
},
{
name: "incoming packet data active",
call: map[string]any{"direction": 1, "state": 0, "mode": 1},
want: false,
},
{
name: "outgoing voice alerting",
call: map[string]any{"direction": 0, "state": 3, "mode": 0},
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isIncomingVoiceCLCC(test.call); got != test.want {
t.Fatalf("isIncomingVoiceCLCC() = %v, want %v", got, test.want)
}
})
}
// EC20/EC25 firmware may expose an active packet-data session in CLCC.
// It must not be treated as an incoming voice call.
dataCalls := parseCLCC(modem.Response{
Lines: []string{`+CLCC: 1,1,0,1,0,"",128`},
Final: "OK",
})
if len(dataCalls) != 1 {
t.Fatalf("parseCLCC() returned %d data calls, want 1", len(dataCalls))
}
if isIncomingVoiceCLCC(dataCalls[0]) {
t.Fatal("active packet-data CLCC record was treated as an incoming voice call")
}
}
func TestRenderCallWebhookTemplate(t *testing.T) {
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
message := IncomingCallNotification{
+186 -5
View File
@@ -56,6 +56,11 @@ type DeviceController interface {
ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error)
}
type cellularIMSController interface {
CellularIMS(context.Context, string) (device.CellularIMSStatus, error)
SetCellularIMS(context.Context, string, bool) (device.CellularIMSStatus, error)
}
type deviceConfigPayload struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -282,7 +287,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
if errors.Is(policyErr, store.ErrNotFound) {
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
})
}
if policyErr != nil {
@@ -614,6 +619,11 @@ func (s *Server) handleDevicePath(
return true
}
return s.handleAPNProfiles(w, r, physicalID)
case "cellular-ims":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleCellularIMS(w, r, config, physicalID)
case "network/public-ip":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
@@ -1002,6 +1012,11 @@ func (s *Server) handleVoWiFiReconnect(
}
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
s.logger.Warn("VoWiFi operation failed",
"category", "vowifi",
"event", "vowifi.operation_failed",
"raw_error", err,
)
switch {
case errors.Is(err, vowifiruntime.ErrNotRegistered):
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime")
@@ -1012,7 +1027,6 @@ func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
case errors.Is(err, vowifi.ErrNotRunning):
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
default:
s.logger.Warn("VoWiFi action rejected", "error", err)
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error())
}
}
@@ -1070,6 +1084,13 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
text += "\n"
}
text += commandErr.Final
s.logger.Warn("AT command rejected by modem",
"category", "hardware",
"event", "hardware.at_rejected",
"device_id", id,
"modem_final", commandErr.Final,
"raw_response", text,
)
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"response": text,
@@ -1378,6 +1399,164 @@ func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physi
return true
}
func (s *Server) handleCellularIMS(
w http.ResponseWriter,
r *http.Request,
config store.Device,
physicalID string,
) bool {
controller, ok := s.devices.(cellularIMSController)
if !ok {
writeError(w, http.StatusNotImplemented, "cellular_ims_unsupported", "cellular IMS control is unavailable")
return true
}
entry, err := s.devices.Get(physicalID)
if err != nil || entry.Snapshot == nil || !entry.Snapshot.SIMReady {
writeError(w, http.StatusConflict, "sim_not_ready", "a ready SIM is required to configure cellular IMS")
return true
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if !validICCID(iccid) {
writeError(w, http.StatusConflict, "iccid_unavailable", "the active SIM ICCID is unavailable")
return true
}
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
} else if policyErr != nil {
s.writeStoreError(w, policyErr)
return true
}
switch r.Method {
case http.MethodGet:
status, statusErr := controller.CellularIMS(r.Context(), physicalID)
if statusErr != nil {
writeError(w, http.StatusUnprocessableEntity, "cellular_ims_unsupported", statusErr.Error())
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": cellularIMSResponse(iccid, policy.CellularIMSEnabled, status)})
case http.MethodPatch:
var request struct {
Enabled *bool `json:"enabled"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if request.Enabled == nil {
writeError(w, http.StatusBadRequest, "invalid_cellular_ims", "enabled is required")
return true
}
policy.CellularIMSEnabled = *request.Enabled
policy.CellularIMSManaged = true
policy.Source = "manual"
if policy.IPVersion == "" {
policy.IPVersion = "IPV4V6"
}
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return true
}
status, applyErr := controller.SetCellularIMS(r.Context(), physicalID, *request.Enabled)
if applyErr != nil {
writeError(w, http.StatusBadGateway, "cellular_ims_apply_failed", "IMS policy was saved but could not be applied: "+applyErr.Error())
return true
}
if status.Rebooting && config.NetworkEnabled && !config.VoWiFiEnabled {
s.restoreCellularDataAfterIMSReboot(config.ID, physicalID, iccid)
}
statusCode := http.StatusOK
if status.Rebooting {
statusCode = http.StatusAccepted
}
writeJSON(w, statusCode, map[string]any{"data": cellularIMSResponse(iccid, *request.Enabled, status)})
default:
w.Header().Set("Allow", "GET, PATCH")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return true
}
// restoreCellularDataAfterIMSReboot rebuilds the QMI/AT data session destroyed
// by AT+CFUN=1,1. The desired data state already lives in the device/card
// policy; this only waits for the same SIM to register again before replaying it.
func (s *Server) restoreCellularDataAfterIMSReboot(configID, physicalID, iccid string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
var lastErr error
for {
select {
case <-ctx.Done():
s.logger.Warn("restore cellular data after IMS reboot timed out", "device_id", configID, "error", lastErr)
return
case <-ticker.C:
}
config, err := s.store.Device(ctx, configID)
if err != nil || !config.NetworkEnabled || config.VoWiFiEnabled {
return
}
entry, err := s.devices.Get(physicalID)
if err != nil || entry.Snapshot == nil || !entry.Snapshot.SIMReady ||
!strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), iccid) ||
!entry.Snapshot.PSAttached {
lastErr = err
continue
}
request := s.cellularNetworkRequest(ctx, config, entry.Snapshot)
restoreCtx, cancelRestore := context.WithTimeout(ctx, 60*time.Second)
_, err = s.devices.SetNetwork(restoreCtx, physicalID, request)
cancelRestore()
if err != nil {
lastErr = err
continue
}
s.logger.Info("restored cellular data after IMS reboot", "device_id", configID, "interface", config.Interface)
return
}
}()
}
func (s *Server) cellularNetworkRequest(ctx context.Context, config store.Device, snapshot *device.Snapshot) device.NetworkRequest {
request := device.NetworkRequest{
Enabled: true, APN: strings.TrimSpace(config.APN), IPVersion: "IPV4V6", Backend: config.DeviceBackend,
}
if snapshot == nil {
return request
}
iccid := strings.TrimSpace(snapshot.ICCID)
policy, err := s.store.CardPolicy(ctx, iccid)
if err != nil {
return request
}
request.APN = strings.TrimSpace(policy.APN)
if policy.IPVersion != "" {
request.IPVersion = policy.IPVersion
}
profile, err := s.store.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion)
if err != nil {
return request
}
request.Username = profile.Username
request.Password = profile.Password
request.Authentication = profile.AuthType
if snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
request.IPVersion = profile.RoamingIPVersion
}
return request
}
func cellularIMSResponse(iccid string, desired bool, status device.CellularIMSStatus) map[string]any {
return map[string]any{
"iccid": iccid, "desired_enabled": desired, "supported": status.Supported,
"configured": status.Configured, "registered": status.Registered,
"cs_known": status.CSKnown, "cs_registered": status.CSRegistered,
"changed": status.Changed, "rebooting": status.Rebooting,
}
}
func (s *Server) handleCellularData(
w http.ResponseWriter,
r *http.Request,
@@ -1509,6 +1688,11 @@ func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool
}
func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
s.logger.Warn("hardware operation failed",
"category", "hardware",
"event", "hardware.operation_failed",
"raw_error", device.HardwareErrorDetail(err),
)
switch {
case errors.Is(err, device.ErrNotFound):
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present")
@@ -1547,9 +1731,6 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
case errors.Is(err, context.Canceled):
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
default:
// Preserve the hardware failure reason in the operator-visible log while
// keeping AT payloads and long APDU material out of it.
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
}
}
@@ -73,6 +73,51 @@ func TestParseModemAPNProfiles(t *testing.T) {
}
}
type fakeCellularIMSController struct {
fakeDeviceController
status device.CellularIMSStatus
setTo *bool
setErr error
}
func (controller *fakeCellularIMSController) CellularIMS(context.Context, string) (device.CellularIMSStatus, error) {
return controller.status, controller.setErr
}
func (controller *fakeCellularIMSController) SetCellularIMS(_ context.Context, _ string, enabled bool) (device.CellularIMSStatus, error) {
controller.setTo = &enabled
return controller.status, controller.setErr
}
func TestCellularIMSPatchPersistsCurrentICCIDsPolicy(t *testing.T) {
test := newSettingsAPITest(t)
const iccid = "898520313000000590"
controller := &fakeCellularIMSController{
fakeDeviceController: fakeDeviceController{entry: device.Device{
ID: "physical-1", Discovered: true,
Snapshot: &device.Snapshot{DeviceID: "physical-1", SIMReady: true, ICCID: iccid},
}},
status: device.CellularIMSStatus{Supported: true, Configured: true, Changed: true, Rebooting: true},
}
test.server.devices = controller
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPatch, "/api/devices/configured-1/cellular-ims", strings.NewReader(`{"enabled":true}`))
request.Header.Set("Content-Type", "application/json")
if !test.server.handleCellularIMS(recorder, request, store.Device{ID: "configured-1"}, "physical-1") {
t.Fatal("handleCellularIMS returned false")
}
if recorder.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
}
if controller.setTo == nil || !*controller.setTo {
t.Fatalf("SetCellularIMS captured %v", controller.setTo)
}
policy, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !policy.CellularIMSManaged || !policy.CellularIMSEnabled {
t.Fatalf("stored policy = %+v, %v", policy, err)
}
}
type esimAIDCaptureController struct {
fakeDeviceController
switchAID string
+31 -5
View File
@@ -152,7 +152,24 @@ func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
if r.Method == http.MethodDelete {
clearedAt := time.Now().UTC()
if s.logs != nil {
s.logs.Clear()
}
deleted, err := s.store.ClearLogEvents(r.Context(), clearedAt)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{"cleared": true, "deleted": deleted},
})
return
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
@@ -170,7 +187,9 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
// backs the live stream).
entries := []loghub.Entry{}
if s.store != nil {
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit})
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{
Limit: limit, ExcludeMessage: "http request",
})
if err != nil {
s.writeStoreError(w, err)
return
@@ -179,11 +198,17 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
if storedLogLevel(event.Level) < minimum {
continue
}
entry := storedLogToEntry(event)
entry := loghub.SanitizeEntry(storedLogToEntry(event))
if loghub.IsHTTPAccessEntry(entry) {
continue
}
if search != "" && !storedLogContains(entry, search) {
continue
}
entries = append(entries, entry)
if len(entries) == limit {
break
}
}
// ListLogEvents is newest-first; present chronologically.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
@@ -283,7 +308,8 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if logLevel(entry.Level) < minimum {
entry = loghub.SanitizeEntry(entry)
if loghub.IsHTTPAccessEntry(entry) || logLevel(entry.Level) < minimum {
continue
}
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
@@ -308,7 +334,7 @@ func logLevel(value string) slog.Level {
return slog.LevelError
case "warn", "warning":
return slog.LevelWarn
case "debug":
case "debug", "all", "":
return slog.LevelDebug
default:
return slog.LevelInfo
+40
View File
@@ -0,0 +1,40 @@
package server
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"vocat/internal/loghub"
"vocat/internal/store"
)
func TestHandleLogHistoryDeleteClearsMemoryAndDatabase(t *testing.T) {
server := newSettingsTestServer(t)
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
server.logs = hub
server.logger = slog.New(hub)
server.logger.Info("memory log")
if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{
Level: "info", Message: "persisted log",
}); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil)
server.handleLogHistory(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("memory history after clear = %#v", history)
}
count, err := server.store.CountLogEvents(context.Background())
if err != nil || count != 0 {
t.Fatalf("persisted count after clear = %d, %v", count, err)
}
}
+13 -3
View File
@@ -36,13 +36,17 @@ func parseLoggingConfig(config loggingConfig) (loggingConfig, error) {
if config.Count < 1 {
config.Count = 10000
}
if config.Count > store.MaxLogEvents {
config.Count = store.MaxLogEvents
}
if config.Days < 1 {
config.Days = 30
}
return config, nil
}
// loadLoggingConfig reads the persisted retention policy, defaulting to unlimited.
// loadLoggingConfig reads the persisted retention policy. "unlimited" means
// no user-selected limit below the global 10,000-row hard ceiling.
func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
config := defaultLoggingConfig()
setting, err := s.store.AppSetting(ctx, loggingSettingKey)
@@ -64,13 +68,17 @@ func (s *Server) applyLogRetention(ctx context.Context) error {
switch config.Mode {
case "days":
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour)
_, err := s.store.PruneLogEvents(ctx, cutoff)
if _, err := s.store.PruneLogEvents(ctx, cutoff); err != nil {
return err
}
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
return err
case "count":
_, err := s.store.PruneLogEventsToCount(ctx, config.Count)
return err
default:
return nil
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
return err
}
}
@@ -116,6 +124,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
"count": config.Count,
"days": config.Days,
"stored_logs": stored,
"max_logs": store.MaxLogEvents,
},
})
case http.MethodPut:
@@ -152,6 +161,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
"count": config.Count,
"days": config.Days,
"stored_logs": stored,
"max_logs": store.MaxLogEvents,
},
})
default:
+10
View File
@@ -199,6 +199,16 @@ func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) {
}
}
func TestLoggingCountIsClampedToHardLimit(t *testing.T) {
config, err := parseLoggingConfig(loggingConfig{Mode: "count", Count: store.MaxLogEvents + 500})
if err != nil {
t.Fatal(err)
}
if config.Count != store.MaxLogEvents {
t.Fatalf("count = %d, want %d", config.Count, store.MaxLogEvents)
}
}
func TestLoginLockoutViaHTTP(t *testing.T) {
app := newTestApplication(t)
for i := 0; i < 4; i++ {
+47 -14
View File
@@ -160,7 +160,7 @@ func New(options Options) (*Server, error) {
mux.HandleFunc("/", server.handleSPA)
server.handler = server.recoverPanics(
server.securityHeaders(server.accessControl(server.logRequests(mux))),
server.securityHeaders(server.accessControl(server.logUserOperation(mux))),
)
return server, nil
}
@@ -557,16 +557,14 @@ func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool
return false
}
type statusWriter struct {
type operationStatusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func (w *operationStatusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *statusWriter) WriteHeader(status int) {
func (w *operationStatusWriter) WriteHeader(status int) {
if w.status != 0 {
return
}
@@ -574,25 +572,60 @@ func (w *statusWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status)
}
func (s *Server) logRequests(next http.Handler) http.Handler {
// logUserOperation records state-changing API actions, not request traffic.
// GET/HEAD polling, assets, health checks and the live log stream are never
// emitted, keeping the diagnostic page focused on actions a user initiated.
func (s *Server) logUserOperation(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
writer := &statusWriter{ResponseWriter: w}
if !strings.HasPrefix(r.URL.Path, "/api/") ||
r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions ||
strings.HasPrefix(r.URL.Path, "/api/auth/") || strings.HasPrefix(r.URL.Path, "/api/logs/") {
next.ServeHTTP(w, r)
return
}
writer := &operationStatusWriter{ResponseWriter: w}
next.ServeHTTP(writer, r)
status := writer.status
if status == 0 {
status = http.StatusOK
}
s.logger.Info(
"http request",
"method", r.Method,
"path", r.URL.Path,
level := slog.LevelInfo
outcome := "success"
message := "user operation completed"
if status >= http.StatusBadRequest {
level = slog.LevelWarn
outcome = "failed"
message = "user operation failed"
}
s.logger.Log(r.Context(), level, message,
"category", operationPathCategory(r.URL.Path),
"event", "user.operation",
"operation", strings.TrimPrefix(r.URL.Path, "/api/"),
"outcome", outcome,
"status", status,
"duration", time.Since(startedAt),
)
})
}
func operationPathCategory(path string) string {
path = strings.ToLower(path)
switch {
case strings.Contains(path, "/sms"):
return "sms"
case strings.Contains(path, "/call"):
return "call"
case strings.Contains(path, "/vowifi") || strings.Contains(path, "/ims"):
return "vowifi"
case strings.Contains(path, "/network") || strings.Contains(path, "/operator"):
return "network"
case strings.Contains(path, "/device") || strings.Contains(path, "/esim") ||
strings.Contains(path, "/ussd") || strings.Contains(path, "/at"):
return "hardware"
default:
return "operation"
}
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
+28
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"io/fs"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
@@ -18,9 +19,36 @@ import (
"golang.org/x/crypto/bcrypt"
"vocat/internal/auth"
"vocat/internal/loghub"
"vocat/internal/store"
)
func TestUserOperationLoggerExcludesReadTraffic(t *testing.T) {
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
server := &Server{logger: slog.New(hub)}
handler := server.logUserOperation(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/devices", nil))
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("GET traffic produced diagnostic logs: %#v", history)
}
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPatch, "/api/devices/dev1/network", nil))
history := hub.History(10, slog.LevelDebug, "")
if len(history) != 1 {
t.Fatalf("mutation log count = %d, want 1", len(history))
}
if history[0].Message != "user operation completed" || history[0].Fields["category"] != "network" {
t.Fatalf("mutation log = %#v", history[0])
}
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil))
if history = hub.History(10, slog.LevelDebug, ""); len(history) != 1 {
t.Fatalf("log clear endpoint produced an operation log: %#v", history)
}
}
type testApplication struct {
server *httptest.Server
client *http.Client
+28 -19
View File
@@ -1405,18 +1405,20 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut:
var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"`
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"`
CellularIMSEnabled *bool `json:"cellular_ims_enabled"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil {
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil &&
request.CellularIMSEnabled == nil {
writeError(
w,
http.StatusBadRequest,
@@ -1464,6 +1466,10 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
}
policy.CustomPhoneNumber = phoneNumber
}
if request.CellularIMSEnabled != nil {
policy.CellularIMSEnabled = *request.CellularIMSEnabled
policy.CellularIMSManaged = true
}
if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled
}
@@ -1499,11 +1505,12 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
func defaultCardPolicy(iccid string) store.CardPolicy {
return store.CardPolicy{
ICCID: strings.TrimSpace(iccid),
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
ICCID: strings.TrimSpace(iccid),
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
CellularIMSManaged: true,
}
}
@@ -1790,14 +1797,16 @@ func normalizeCustomPhoneNumber(value string) (string, error) {
func cardPolicyResponse(policy store.CardPolicy) map[string]any {
response := map[string]any{
"iccid": policy.ICCID,
"network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN,
"ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber,
"source": policy.Source,
"iccid": policy.ICCID,
"network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN,
"ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber,
"cellular_ims_enabled": policy.CellularIMSEnabled,
"cellular_ims_managed": policy.CellularIMSManaged,
"source": policy.Source,
}
if !policy.CreatedAt.IsZero() {
response["created_at"] = policy.CreatedAt
+10 -1
View File
@@ -668,7 +668,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" {
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" ||
policy["cellular_ims_enabled"] != false || policy["cellular_ims_managed"] != true {
t.Fatalf("default policy = %#v", policy)
}
@@ -681,6 +682,14 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"cellular_ims_enabled":true}`)
if recorder.Code != http.StatusOK {
t.Fatalf("cellular IMS policy status = %d, body = %s", recorder.Code, recorder.Body)
}
storedIMS, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !storedIMS.CellularIMSEnabled || !storedIMS.CellularIMSManaged {
t.Fatalf("stored cellular IMS policy = %+v, %v", storedIMS, err)
}
response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any)
if policy["custom_phone_number"] != "+8613800138000" {
+56 -4
View File
@@ -369,16 +369,26 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
if sendErr != nil {
data["retry_safe"] = false
if result.PartsAccepted > 0 {
s.logger.Warn("multipart SMS was only partially accepted",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "parts_attempted", result.PartsAttempted,
"parts_accepted", result.PartsAccepted, "raw_error", sendErr,
)
data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
return
}
s.logger.Warn(
"SMS submission failed after modem interaction",
"category", "sms",
"event", "sms.submission",
"device_id", request.DeviceID,
"peer", request.Phone,
"transport", "cellular_at",
"parts_attempted", result.PartsAttempted,
"parts_accepted", result.PartsAccepted,
"error", sendErr,
"raw_error", sendErr,
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{
@@ -390,6 +400,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
return
}
if !result.AllPartsAccepted {
s.logger.Warn("SMS submission was not confirmed",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "modem_final", result.ModemFinal,
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{
Code: "sms_submission_unconfirmed",
@@ -399,6 +415,11 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
})
return
}
s.logger.Info("SMS submission accepted",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "parts", result.PartsAccepted,
)
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
}
@@ -475,6 +496,12 @@ func (s *Server) writeIMSSMSSendResult(
"outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
}
if sendErr != nil {
s.logger.Warn("IMS SMS submission failed",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
"raw_error", sendErr,
)
data["retry_safe"] = false
data["warning"] = sendErr.Error()
if result.PartsAccepted == 0 {
@@ -489,6 +516,11 @@ func (s *Server) writeIMSSMSSendResult(
}
}
if !result.AllPartsAccepted && result.PartsAccepted == 0 {
s.logger.Warn("IMS SMS submission was not confirmed",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{
Code: "ims_sms_submission_unconfirmed",
@@ -498,6 +530,19 @@ func (s *Server) writeIMSSMSSendResult(
})
return
}
if result.AllPartsAccepted {
s.logger.Info("IMS SMS submission accepted",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts", result.PartsAccepted,
)
} else {
s.logger.Warn("multipart IMS SMS was only partially accepted",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
}
@@ -653,7 +698,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
"delivery_status": message.DeliveryStatus,
"data_coding_scheme": message.DataCodingScheme,
})
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
saved, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
MessageID: messageID,
DeviceID: config.ID,
ModemIMEI: modemIMEI,
@@ -670,7 +715,14 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
Extra: extra,
})
if saveErr != nil {
s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr)
s.logger.Warn("persist modem SMS failed", "category", "sms", "device_id", config.ID, "raw_error", saveErr)
} else if saved.Direction == "inbound" && saved.CreatedAt.Unix() == saved.UpdatedAt.Unix() {
s.logger.Info("cellular SMS received",
"category", "sms", "event", "sms.received",
"device_id", config.ID, "peer", saved.Peer,
"transport", "cellular_at", "encoding", message.Encoding,
"parts", saved.PartsTotal,
)
}
}
}
@@ -770,6 +822,6 @@ func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
return
}
s.logger.Error("database operation failed", "error", err)
s.logger.Error("database operation failed", "category", "system", "event", "store.operation_failed", "raw_error", err)
writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
}
+13 -4
View File
@@ -279,8 +279,8 @@ func TestMigration19AcceptsDevelopmentDatabaseAndPreservesCardData(t *testing.T)
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != 19 {
t.Fatalf("schema version = %d, want 19", version)
if version != schemaVersion {
t.Fatalf("schema version = %d, want %d", version, schemaVersion)
}
for _, column := range []string{
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
@@ -333,8 +333,8 @@ func TestMigration19AcceptsDevelopmentColumnsAlreadyPresent(t *testing.T) {
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != 19 {
t.Fatalf("schema version = %d, want 19", version)
if version != schemaVersion {
t.Fatalf("schema version = %d, want %d", version, schemaVersion)
}
}
@@ -1017,6 +1017,15 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("log filter result = %+v, %v", logs, err)
}
if _, err := database.AppendLogEvent(ctx, LogEvent{
Time: recent, Level: "info", Message: " HTTP REQUEST ",
}); err != nil {
t.Fatal(err)
}
logs, err = database.ListLogEvents(ctx, LogFilter{Level: "info", ExcludeMessage: "http request"})
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("excluded log filter result = %+v, %v", logs, err)
}
auditDeleted, logDeleted, err := database.PruneEvents(
ctx,
old.Add(time.Minute),
+56 -2
View File
@@ -9,6 +9,10 @@ import (
"time"
)
// MaxLogEvents is the hard storage ceiling. Every new row beyond this limit
// replaces the oldest row regardless of the optional, stricter retention rule.
const MaxLogEvents = 10000
func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
value.Action = strings.TrimSpace(value.Action)
if value.Action == "" {
@@ -123,6 +127,8 @@ func auditEvent(row rowScanner) (AuditEvent, error) {
}
func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) {
s.logMu.Lock()
defer s.logMu.Unlock()
value.Level = strings.ToLower(strings.TrimSpace(value.Level))
if value.Level == "" {
return LogEvent{}, errors.New("log level is required")
@@ -137,7 +143,18 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
if value.Time.IsZero() {
value.Time = time.Now().UTC()
}
result, err := s.db.ExecContext(ctx, `
value.Fields = fields
if !s.logClearedAt.IsZero() && !value.Time.After(s.logClearedAt) {
// The entry was queued before a user cleared the log. Silently discard it
// so an in-flight persistence worker cannot resurrect cleared history.
return value, nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return LogEvent{}, fmt.Errorf("begin log append: %w", err)
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
INSERT INTO log_events (event_time, level, message, caller, fields_json)
VALUES (?, ?, ?, ?, ?)
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields))
@@ -148,7 +165,17 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
if err != nil {
return LogEvent{}, fmt.Errorf("read log event id: %w", err)
}
value.Fields = fields
if _, err := tx.ExecContext(ctx, `
DELETE FROM log_events
WHERE id <= COALESCE((
SELECT id FROM log_events ORDER BY id DESC LIMIT 1 OFFSET ?
), 0)
`, MaxLogEvents); err != nil {
return LogEvent{}, fmt.Errorf("enforce log event limit: %w", err)
}
if err := tx.Commit(); err != nil {
return LogEvent{}, fmt.Errorf("commit log append: %w", err)
}
return value, nil
}
@@ -159,6 +186,10 @@ func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent
clauses = append(clauses, `level = ?`)
args = append(args, strings.ToLower(filter.Level))
}
if filter.ExcludeMessage != "" {
clauses = append(clauses, `LOWER(TRIM(message)) <> ?`)
args = append(args, strings.ToLower(strings.TrimSpace(filter.ExcludeMessage)))
}
if !filter.Since.IsZero() {
clauses = append(clauses, `event_time >= ?`)
args = append(args, filter.Since.UTC().Unix())
@@ -236,6 +267,29 @@ func (s *Store) CountLogEvents(ctx context.Context) (int64, error) {
return count, nil
}
// ClearLogEvents permanently removes all persisted logs. Entries timestamped
// at or before clearedAt are also rejected if they were already queued by the
// asynchronous persistence worker.
func (s *Store) ClearLogEvents(ctx context.Context, clearedAt time.Time) (int64, error) {
s.logMu.Lock()
defer s.logMu.Unlock()
if clearedAt.IsZero() {
clearedAt = time.Now().UTC()
}
result, err := s.db.ExecContext(ctx, `DELETE FROM log_events`)
if err != nil {
return 0, fmt.Errorf("clear log events: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("read cleared log count: %w", err)
}
if clearedAt.After(s.logClearedAt) {
s.logClearedAt = clearedAt
}
return affected, nil
}
// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
// rest. keep <= 0 deletes everything.
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) {
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"context"
"fmt"
"testing"
"time"
)
func TestAppendLogEventEnforcesHardLimit(t *testing.T) {
database, err := Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
defer database.Close()
if _, err := database.db.ExecContext(context.Background(), `
WITH RECURSIVE sequence(value) AS (
SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value <= ?
)
INSERT INTO log_events(event_time, level, message, caller, fields_json)
SELECT value, 'info', 'seed-' || value, '', '{}' FROM sequence
`, MaxLogEvents); err != nil {
t.Fatal(err)
}
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "newest", Time: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
count, err := database.CountLogEvents(context.Background())
if err != nil || count != MaxLogEvents {
t.Fatalf("CountLogEvents = %d, %v; want %d", count, err, MaxLogEvents)
}
logs, err := database.ListLogEvents(context.Background(), LogFilter{Limit: 1})
if err != nil || len(logs) != 1 || logs[0].Message != "newest" {
t.Fatalf("newest log = %#v, %v", logs, err)
}
}
func TestClearLogEventsRejectsAlreadyQueuedEntries(t *testing.T) {
database, err := Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
defer database.Close()
cutoff := time.Now().UTC()
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "existing", Time: cutoff.Add(-time.Second),
}); err != nil {
t.Fatal(err)
}
deleted, err := database.ClearLogEvents(context.Background(), cutoff)
if err != nil || deleted != 1 {
t.Fatalf("ClearLogEvents = %d, %v", deleted, err)
}
late, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "queued-before-clear", Time: cutoff.Add(-time.Millisecond),
})
if err != nil || late.ID != 0 {
t.Fatalf("old queued append = %+v, %v", late, err)
}
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: fmt.Sprintf("new-%d", MaxLogEvents), Time: cutoff.Add(time.Millisecond),
}); err != nil {
t.Fatal(err)
}
count, err := database.CountLogEvents(context.Background())
if err != nil || count != 1 {
t.Fatalf("CountLogEvents = %d, %v; want 1", count, err)
}
}
+9
View File
@@ -381,6 +381,15 @@ func migrationStatements(version int) []string {
`ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`,
`ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`,
}
case 20:
return []string{
`ALTER TABLE card_policies
ADD COLUMN cellular_ims_enabled INTEGER NOT NULL DEFAULT 0
CHECK (cellular_ims_enabled IN (0, 1))`,
`ALTER TABLE card_policies
ADD COLUMN cellular_ims_managed INTEGER NOT NULL DEFAULT 0
CHECK (cellular_ims_managed IN (0, 1))`,
}
default:
return nil
}
+18 -15
View File
@@ -467,24 +467,27 @@ type LogEvent struct {
}
type LogFilter struct {
Level string
Since time.Time
Until time.Time
BeforeID int64
Limit int
Level string
ExcludeMessage string
Since time.Time
Until time.Time
BeforeID int64
Limit int
}
type CardPolicy struct {
ICCID string
NetworkEnabled bool
VoWiFiEnabled bool
AirplaneEnabled bool
APN string
IPVersion string
CustomPhoneNumber string
Source string
CreatedAt time.Time
UpdatedAt time.Time
ICCID string
NetworkEnabled bool
VoWiFiEnabled bool
AirplaneEnabled bool
APN string
IPVersion string
CustomPhoneNumber string
CellularIMSEnabled bool
CellularIMSManaged bool
Source string
CreatedAt time.Time
UpdatedAt time.Time
}
type CardAPNProfile struct {
+14 -6
View File
@@ -386,8 +386,9 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, custom_phone_number, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
apn, ip_version, custom_phone_number, cellular_ims_enabled, cellular_ims_managed,
source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET
network_enabled = excluded.network_enabled,
vowifi_enabled = excluded.vowifi_enabled,
@@ -395,12 +396,15 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
apn = excluded.apn,
ip_version = excluded.ip_version,
custom_phone_number = excluded.custom_phone_number,
cellular_ims_enabled = excluded.cellular_ims_enabled,
cellular_ims_managed = excluded.cellular_ims_managed,
source = excluded.source,
updated_at = excluded.updated_at
`,
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(),
value.CustomPhoneNumber, boolInt(value.CellularIMSEnabled), boolInt(value.CellularIMSManaged), value.Source,
createdAt.Unix(), updatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
@@ -446,16 +450,18 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error {
const cardPolicySelect = `
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, custom_phone_number, source, created_at, updated_at
apn, ip_version, custom_phone_number, cellular_ims_enabled, cellular_ims_managed,
source, created_at, updated_at
FROM card_policies`
func cardPolicy(row rowScanner) (CardPolicy, error) {
var value CardPolicy
var networkEnabled, vowifiEnabled, airplaneEnabled int
var networkEnabled, vowifiEnabled, airplaneEnabled, cellularIMSEnabled, cellularIMSManaged int
var createdAt, updatedAt int64
err := row.Scan(
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt,
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &cellularIMSEnabled, &cellularIMSManaged,
&value.Source, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return CardPolicy{}, ErrNotFound
@@ -466,6 +472,8 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
value.NetworkEnabled = networkEnabled != 0
value.VoWiFiEnabled = vowifiEnabled != 0
value.AirplaneEnabled = airplaneEnabled != 0
value.CellularIMSEnabled = cellularIMSEnabled != 0
value.CellularIMSManaged = cellularIMSManaged != 0
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil
+5 -2
View File
@@ -8,18 +8,21 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
const schemaVersion = 19
const schemaVersion = 20
var ErrNotFound = errors.New("store: not found")
// Store owns the SQLite connection used by the process.
type Store struct {
db *sql.DB
db *sql.DB
logMu sync.Mutex
logClearedAt time.Time
}
type Admin struct {
+33 -19
View File
@@ -42,8 +42,10 @@ type CarrierProfile struct {
IMSRegisterProfile string
IMSIPSecEncryption string
SMSCenter string
PANIEnabled *bool
PANICountry string
PANINode string
IMSUserAgent string
IMSDialURIScheme string
IMSUserEqPhone bool
IMSVoiceCodecs []string
@@ -57,7 +59,6 @@ type IMSRegisterOptions struct {
ContactExtraTags []string
SupportedHeader *string
AllowHeader *string
UserAgent string
PPreferredIdentity bool
PVisitedNetworkID string
PAccessNetworkInfo *string
@@ -68,6 +69,7 @@ type IMSRegisterOptions struct {
const (
IMSContactFormatStandard = "standard"
IMSContactFormatATT = "att"
IMSContactFormatGSMA = "gsma"
)
type carrierProfileDocument struct {
@@ -76,13 +78,13 @@ type carrierProfileDocument struct {
}
type carrierProfileRule struct {
ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"`
ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"`
}
type carrierProfileMatch struct {
@@ -116,8 +118,10 @@ type carrierProfileIMS struct {
RegisterProfile string `json:"register_profile,omitempty"`
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
SMSCenter string `json:"sms_center,omitempty"`
PANIEnabled *bool `json:"pani_enabled,omitempty"`
PANICountry string `json:"pani_country,omitempty"`
PANINode string `json:"pani_node,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
VoiceCodecs []string `json:"voice_codecs,omitempty"`
@@ -131,7 +135,6 @@ type carrierProfileRegisterOptions struct {
ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
SupportedHeader *string `json:"supported_header,omitempty"`
AllowHeader *string `json:"allow_header,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
@@ -322,6 +325,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
country != "AUTO" &&
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
return false
}
@@ -340,7 +344,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
format != IMSContactFormatStandard && format != IMSContactFormatATT {
format != IMSContactFormatStandard && format != IMSContactFormatATT && format != IMSContactFormatGSMA {
return false
}
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
@@ -353,7 +357,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
}
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
for _, value := range []string{rule.IMS.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
if strings.ContainsAny(value, "\r\n") {
return false
}
@@ -479,8 +483,12 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
}{
{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},
// GID values identify an MVNO/service profile within a host network and
// therefore outrank the host issuer's broad ICCID prefix. Otherwise a
// home-PLMN+ICCID AT&T rule hides RedPocket/Cricket/etc. even when the SIM
// exposes the carrier bundle's exact GID selector.
{name: "gid1", weight: 90, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
{name: "gid2", weight: 85, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
} {
if len(selector.values) == 0 {
continue
@@ -584,8 +592,15 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
base.IMSIPSecEncryption = value
}
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
if rule.IMS.PANIEnabled != nil {
enabled := *rule.IMS.PANIEnabled
base.PANIEnabled = &enabled
}
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
if value := strings.TrimSpace(rule.IMS.UserAgent); value != "" {
base.IMSUserAgent = value
}
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
base.IMSDialURIScheme = value
}
@@ -620,9 +635,6 @@ func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOp
value := strings.TrimSpace(*rule.AllowHeader)
base.AllowHeader = &value
}
if value := strings.TrimSpace(rule.UserAgent); value != "" {
base.UserAgent = value
}
if rule.PPreferredIdentity {
base.PPreferredIdentity = true
}
@@ -719,8 +731,8 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.ICCID) != "" {
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
imsiCountry := countryCodeForMCC(identity.HomeMCC)
iccidCountry := countryCodeForMCC(mcc)
imsiCountry := CountryCodeForMCC(identity.HomeMCC)
iccidCountry := CountryCodeForMCC(mcc)
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
identity.HomeMCC = mcc
identity.HomeMNC = mnc
@@ -733,7 +745,9 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
return identity
}
func countryCodeForMCC(mcc string) string {
// CountryCodeForMCC returns the ISO 3166-1 alpha-2 country code associated
// with an MCC known to the carrier compatibility database.
func CountryCodeForMCC(mcc string) string {
switch strings.TrimSpace(mcc) {
case "515":
return "PH"
+38
View File
@@ -46,6 +46,31 @@ func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) {
}
}
func TestResolveCarrierProfileGiffgaffIMSHeaders(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
})
options := profile.IMSRegisterOptions
if profile.IMSTransport != "tcp" || options.ContactFormat != IMSContactFormatGSMA {
t.Fatalf("giffgaff IMS transport/contact profile = %#v", profile)
}
if profile.IMSUserAgent != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff User-Agent = %q", profile.IMSUserAgent)
}
if options.SupportedHeader != nil || options.AllowHeader != nil {
t.Fatalf("giffgaff REGISTER header overrides = supported=%v allow=%v", options.SupportedHeader, options.AllowHeader)
}
if options.PAccessNetworkInfo != nil {
t.Fatalf("giffgaff unexpectedly defines a carrier PANI override = %v", *options.PAccessNetworkInfo)
}
if profile.PANIEnabled == nil || !*profile.PANIEnabled || profile.PANICountry != "AUTO" {
t.Fatalf("giffgaff PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if len(options.ContactExtraTags) != 2 || options.ContactExtraTags[0] != "+g.3gpp.mid-call" || options.ContactExtraTags[1] != "+g.3gpp.smsip" {
t.Fatalf("giffgaff Contact tags = %#v", options.ContactExtraTags)
}
}
func TestResolveCarrierProfileATT(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
@@ -55,6 +80,16 @@ func TestResolveCarrierProfileATT(t *testing.T) {
}
}
func TestResolveCarrierProfileRedPocketOutranksBroadATTICCID(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310170000000001",
HomeMCC: "310", HomeMNC: "170", SPN: "Red Pocket", GID1: "42FFFF",
})
if profile.ID != "ipcc-redpocket-310170" || profile.MatchSource != "hplmn+gid1" {
t.Fatalf("RedPocket profile = %#v", profile)
}
}
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
if profile.ID != CarrierProfileStandard {
@@ -69,6 +104,9 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
if profile.IMSRegisterOptions.SupportedHeader != nil {
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
}
if profile.PANIEnabled != nil || profile.PANICountry != "" {
t.Fatalf("standard PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if profile.AllowSMSWithoutContactConfirmation {
t.Fatal("standard profile should require SMS contact confirmation")
}
+5 -4
View File
@@ -635,6 +635,11 @@ func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ip
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
}
}
if strings.EqualFold(plistString(signaling.value["CountryOfOriginationFormat"]), "PANI") {
enabled := true
rule.IMS.PANIEnabled = &enabled
rule.IMS.PANICountry = "AUTO"
}
}
}
if useIPSec {
@@ -661,10 +666,6 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
case key == "countryoforiginationformat":
// PANI is access/session metadata, not a carrier location constant.
// The IMS runtime provides one globally and consistently across
// REGISTER, MESSAGE, RP-ACK and dialogs, so no profile field is needed.
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
}
+3
View File
@@ -76,6 +76,9 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
if rule.IMS.IPSecEncryption != "aes-cbc" {
t.Fatalf("converted IMS profile = %#v", rule.IMS)
}
if rule.IMS.PANIEnabled == nil || !*rule.IMS.PANIEnabled || rule.IMS.PANICountry != "AUTO" {
t.Fatalf("converted PANI behavior = enabled=%v country=%q", rule.IMS.PANIEnabled, rule.IMS.PANICountry)
}
for _, code := range []string{
"remote_certificate_bypass_ignored",
"disabled_dpd_ignored",
+33 -2
View File
@@ -570,6 +570,14 @@
{
"id": "ipcc-redpocket-310170",
"match_any": [
{
"home_plmns": [
"310170"
],
"gid1_prefixes": [
"42"
]
},
{
"home_plmns": [
"310410"
@@ -585,6 +593,18 @@
"gid1_prefixes": [
"42"
]
},
{
"home_plmns": [
"310170",
"310410",
"310280"
],
"spns": [
"Red Pocket",
"RedPocket",
"Red Pocket Mobile"
]
}
],
"epdg": {
@@ -5766,7 +5786,18 @@
"proposal": "modern"
},
"ims": {
"ipsec_encryption": "aes-cbc"
"transport": "tcp",
"ipsec_encryption": "aes-cbc",
"pani_enabled": true,
"pani_country": "AUTO",
"user_agent": "iOS/18.6.2 iPhone",
"register_options": {
"contact_format": "gsma",
"contact_extra_tags": [
"+g.3gpp.mid-call",
"+g.3gpp.smsip"
]
}
}
},
{
@@ -11783,4 +11814,4 @@
}
],
"version": 1
}
}
+82 -1
View File
@@ -29,6 +29,7 @@ var (
const (
usimAIDPrefix = "A0000000871002"
isimAIDPrefix = "A0000000871004"
efDIRFileID = 0x2f00
efADDecimal = 28589 // 0x6FAD
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
channelCleanupTimeout = 3 * time.Second
@@ -1025,6 +1026,19 @@ func (adapter *EC20Adapter) discoverAKAApplication(
}
}
}
// CUAD is optional and is rejected by a number of EC20 firmware branches.
// In that case do not immediately fall back to the seven-byte registered
// application-provider prefix: cards may expose multiple USIM instances and
// require the complete PIX from EF_DIR to select the provisioned one. Read
// EF_DIR over the standards-based basic channel, which remains available on
// the same firmware that rejects CCHO/CGLA.
if discovered, discoverErr := adapter.discoverBasicApplicationAID(
ctx,
deviceID,
usimAIDPrefix,
); discoverErr == nil {
return discovered, "USIM", nil
}
// AT+CUAD is optional on older EC20 firmware. CCHO still provides a
// standards-based, evidence-bearing probe of the assigned USIM AID.
@@ -1053,6 +1067,64 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication(
return aidPrefix, application, nil
}
func (adapter *EC20Adapter) discoverBasicApplicationAID(
ctx context.Context,
deviceID string,
aidPrefix string,
) (string, error) {
selectFile := func(fileID uint16) error {
apdu := []byte{
0x00, 0xa4, 0x00, 0x04, 0x02,
byte(fileID >> 8), byte(fileID), 0x00,
}
raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false)
if err != nil {
return err
}
_, status, err := splitAPDUStatus(raw)
if err != nil {
return err
}
if status != 0x9000 {
return fmt.Errorf("vocat: EC20 basic-channel SELECT returned %04X", status)
}
return nil
}
if err := selectFile(0x3f00); err != nil {
return "", fmt.Errorf("select EC20 MF for application discovery: %w", err)
}
if err := selectFile(efDIRFileID); err != nil {
return "", fmt.Errorf("select EC20 EF_DIR for application discovery: %w", err)
}
for record := 1; record <= 32; record++ {
raw, err := adapter.transmitBasicAPDU(
ctx,
deviceID,
[]byte{0x00, 0xb2, byte(record), 0x04, 0x00},
false,
)
if err != nil {
return "", fmt.Errorf("read EC20 EF_DIR record %d: %w", record, err)
}
body, status, err := splitAPDUStatus(raw)
if err != nil {
return "", err
}
if status == 0x6a83 || status == 0x9402 {
break
}
if status != 0x9000 {
continue
}
for _, candidate := range collectApplicationAIDs(body) {
if strings.HasPrefix(candidate, aidPrefix) {
return candidate, nil
}
}
}
return "", ErrEC20ApplicationAbsent
}
func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context,
deviceID string,
@@ -1174,8 +1246,17 @@ func (adapter *EC20Adapter) transmitBasicAPDU(
if err != nil {
return nil, err
}
collected = append(collected, body...)
sw1 := byte(status >> 8)
if sw1 == 0x6c {
// The UICC knows the exact response length. Retry the original APDU
// with the advised Le without retaining the procedure response.
if len(current) < 5 {
return nil, errors.New("vocat: EC20 APDU cannot apply corrected response length")
}
current[len(current)-1] = byte(status)
continue
}
collected = append(collected, body...)
if sw1 != 0x61 && sw1 != 0x9f {
collected = append(collected, byte(status>>8), byte(status))
return collected, nil
+53
View File
@@ -263,6 +263,59 @@ func TestEC20AdapterCSIMFallbackSupportsSuccessAndSynchronizationFailure(
}
}
func TestEC20AdapterDiscoversFullUSIMAIDFromEFDIRWhenCUADFails(t *testing.T) {
t.Parallel()
const fullAID = "A0000000871002FFFFFFFF8903020000"
record := "61184F10" + fullAID + "50045553494D"
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
var challenge AKAChallenge
for index := range challenge.RAND {
challenge.RAND[index] = byte(index)
challenge.AUTN[index] = byte(0xf0 + index)
}
authAPDU := buildUSIMAuthenticateAPDU(challenge)
authCommand := fmt.Sprintf(
`AT+CSIM=%d,"%s"`,
len(authAPDU)*2,
strings.ToUpper(hex.EncodeToString(authAPDU)),
)
selectApplication := `AT+CSIM=42,"00A4040410` + fullAID + `"`
transcript := &ec20Transcript{
t: t,
steps: append(
identityTranscriptStepsWithoutEFAD("310280000000001"),
[]ec20TranscriptStep{
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: "AT+CUAD", err: errors.New("+CME ERROR: 13"), final: "+CME ERROR: 13"},
{command: `AT+CSIM=16,"00A40004023F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=16,"00A40004022F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=10,"00B2010400"`, lines: []string{`+CSIM: 4,"6C1A"`}},
{command: `AT+CSIM=10,"00B201041A"`, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s9000"`, len(record)+4, record)}},
{command: `AT+CCHO="` + fullAID + `"`, err: errors.New("unsupported"), final: "ERROR"},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: authCommand, sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encodedResponse), encodedResponse)}},
}...,
),
}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if _, err := adapter.CheckReady(context.Background(), identity); err != nil {
t.Fatalf("CheckReady: %v", err)
}
if _, err := adapter.Authenticate(context.Background(), identity, challenge); err != nil {
t.Fatalf("Authenticate: %v", err)
}
transcript.assertDone()
}
func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse(
t *testing.T,
) {
+5 -1
View File
@@ -36,8 +36,12 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
var systemErr error
for _, targetHost := range hostsToTry {
addresses, err := resolver.LookupIPAddr(ctx, targetHost)
ips, err := resolver.LookupIP(ctx, "ip4", targetHost)
if err == nil {
addresses := make([]net.IPAddr, 0, len(ips))
for _, ip := range ips {
addresses = append(addresses, net.IPAddr{IP: ip})
}
valid := filterValidPublicEPDGAddresses(addresses)
if len(valid) > 0 {
return valid, nil
+3 -1
View File
@@ -393,9 +393,11 @@ func parseInnerIPv6(packet []byte) (innerPacketMetadata, error) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated")
}
payloadLength := int(binary.BigEndian.Uint16(packet[4:6]))
if payloadLength+40 != len(packet) {
declaredLength := payloadLength + 40
if declaredLength > len(packet) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid")
}
packet = packet[:declaredLength]
metadata := innerPacketMetadata{
source: append(net.IP(nil), packet[8:24]...),
destination: append(net.IP(nil), packet[24:40]...),
+20
View File
@@ -318,6 +318,26 @@ func TestParseInnerIPv6ESP(t *testing.T) {
}
}
func TestParseInnerIPv6ESPTrimsTrailingAlignmentBytes(t *testing.T) {
t.Parallel()
packet := make([]byte, 40+20+4)
packet[0] = 0x60
binary.BigEndian.PutUint16(packet[4:6], 20)
packet[6] = 6
packet[7] = 64
copy(packet[8:24], net.ParseIP("2001:db8::1").To16())
copy(packet[24:40], net.ParseIP("2001:db8::2").To16())
binary.BigEndian.PutUint16(packet[40:42], 49686)
binary.BigEndian.PutUint16(packet[42:44], 5060)
metadata, err := parseInnerPacket(packet)
if err != nil {
t.Fatal(err)
}
if metadata.protocol != 6 || metadata.sourcePort != 49686 || metadata.destinationPort != 5060 {
t.Fatalf("metadata = %+v", metadata)
}
}
func mustDefaultESPTunnel(t *testing.T) *espTunnel {
t.Helper()
return mustTestESPTunnel(
+45 -17
View File
@@ -115,8 +115,10 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
"P-Preferred-Identity: <"+preferredIdentity+">",
"P-Preferred-Service: "+mmtelServiceURN,
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
)
lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
"Supported: 100rel, timer, replaces",
"Session-Expires: 1800;refresher=uac",
@@ -146,6 +148,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS call started",
"category", "call",
"device_id", session.request.DeviceID,
"direction", "outgoing",
"identity_source", identitySource,
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
@@ -228,6 +232,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
} else {
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode,
"error", safeSIPDiagnostic(ackErr.Error()),
@@ -368,6 +374,14 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
session.callMu.Lock()
session.calls[callID] = call
session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS incoming call received",
"category", "call",
"device_id", session.request.DeviceID,
"caller", call.public.Number,
"call_id", call.public.ID,
)
}
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
calledNumber := identityNumber(request.value("To"))
if calledNumber == "" {
@@ -487,8 +501,10 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon
"To: "+to,
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d ACK", call.cseq),
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
)
lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
session.writeMu.Lock()
@@ -565,8 +581,10 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
lines = append(lines,
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
)
lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
@@ -697,10 +715,10 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d %s", cseq, method),
"Supported: 100rel, timer",
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
)
if method != "CANCEL" {
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
lines = session.appendPAccessNetworkInfoHeader(lines)
}
if method == "UPDATE" {
lines = append(lines, session.dialogContactHeader())
@@ -759,6 +777,13 @@ func (session *Session) dialogContactHeader() string {
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
return ""
}
if session.imsRegisterOptions().ContactFormat == vowifi.IMSContactFormatGSMA {
contact := "Contact: <sip:" + session.contactAddress() + `>;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
}
return contact
}
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
@@ -799,19 +824,20 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
}
func (session *Session) pAccessNetworkInfo() string {
if session.paniResolved {
return ueProvidedPANI(session.pani)
if session == nil {
return ""
}
return sessionPAccessNetworkInfo(session.instanceID)
if session.paniResolved {
return session.pani
}
return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger())
}
func (session *Session) callUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
func (session *Session) appendPAccessNetworkInfoHeader(lines []string) []string {
if pani := session.pAccessNetworkInfo(); pani != "" {
return append(lines, "P-Access-Network-Info: "+pani)
}
return "vocat/1"
return lines
}
func callResponseDiagnostic(response *sipResponse) string {
@@ -837,6 +863,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
return
}
session.provider.config.Logger.Info("IMS call response",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode,
"diagnostic", diagnostic,
+53 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -229,7 +231,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
"P-Preferred-Identity: <tel:+447700900123>\r\n",
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
"P-Access-Network-Info: " + sessionPAccessNetworkInfo(session.instanceID) + "\r\n",
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode + "\r\n",
"User-Agent: VoCat Test\r\n",
"Accept: application/sdp\r\n",
} {
@@ -239,6 +241,56 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
}
}
func TestDialogRequestOmitsPAccessNetworkInfoWhenProfileDisablesPANI(t *testing.T) {
profileDir := t.TempDir()
profile := `{"version":1,"profiles":[{"id":"test-pani-disabled","match":{"home_plmns":["00101"]},"ims":{"pani_enabled":false}}]}`
if err := os.WriteFile(filepath.Join(profileDir, "pani-disabled.json"), []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
emptyProfileDir := t.TempDir()
t.Cleanup(func() {
if err := vowifi.LoadCarrierProfileDirectory(emptyProfileDir); err != nil {
t.Errorf("clear external carrier profiles: %v", err)
}
})
if err := vowifi.LoadCarrierProfileDirectory(profileDir); err != nil {
t.Fatal(err)
}
identity := vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01", IMSI: "001010123456789"}
pani := resolveSessionPAccessNetworkInfo(identity, nil)
if pani != "" {
t.Fatalf("disabled profile PANI = %q, want empty", pani)
}
client, peer := net.Pipe()
t.Cleanup(func() {
if err := client.Close(); err != nil {
t.Errorf("close client connection: %v", err)
}
})
t.Cleanup(func() {
if err := peer.Close(); err != nil {
t.Errorf("close peer connection: %v", err)
}
})
session := &Session{
request: vowifi.IMSRequest{Identity: identity},
transport: "tcp",
conn: client,
}
call := &imsCall{
target: "sip:[email protected]",
from: "<sip:[email protected]>;tag=local",
to: "<sip:[email protected]>;tag=remote",
callID: "pani-disabled-call",
}
request := string(session.buildDialogRequest(call, "BYE", 2))
if strings.Contains(request, "\r\nP-Access-Network-Info:") {
t.Fatalf("BYE contains disabled P-Access-Network-Info header:\n%s", request)
}
}
func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
session := &Session{
identity: identitySet{
+128 -26
View File
@@ -4,7 +4,6 @@ import (
"bufio"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
@@ -24,6 +23,7 @@ const (
defaultRegistrationExpiry = 3600 * time.Second
defaultTransactionTimeout = 12 * time.Second
maxAuthenticationChallenges = 3
defaultPANIWLANNode = "ffffffffffff"
)
var (
@@ -649,6 +649,11 @@ func newSession(
if err != nil {
return nil, err
}
instanceURI := "urn:uuid:" + instanceID
profile := vowifi.ResolveCarrierProfile(request.Identity)
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
instanceURI = sipInstanceID(request.Identity, instanceID)
}
refreshContext, refreshCancel := context.WithCancel(context.Background())
session := &Session{
provider: provider,
@@ -660,8 +665,8 @@ func newSession(
conn: connection,
callID: callToken + "@" + addressHost(connection.LocalAddr()),
fromTag: fromTag,
instanceID: "urn:uuid:" + instanceID,
pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID),
instanceID: instanceURI,
pani: resolveSessionPAccessNetworkInfo(request.Identity, provider.config.Logger),
paniResolved: true,
cseq: 1,
refreshContext: refreshContext,
@@ -970,11 +975,7 @@ func (session *Session) buildRegister(
allow = *registerOptions.AllowHeader
}
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
if override := strings.TrimSpace(registerOptions.UserAgent); override != "" &&
(userAgent == "" || userAgent == "vocat/1") {
userAgent = override
}
userAgent := session.imsUserAgent()
lines := []string{
"REGISTER " + requestURI + " SIP/2.0",
@@ -1064,6 +1065,15 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
`%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
base, extra, icsiRef, instanceID,
)
case vowifi.IMSContactFormatGSMA:
extra := ""
for _, tag := range registerOptions.ContactExtraTags {
extra += ";" + tag
}
return fmt.Sprintf(
`<sip:%s>;+g.3gpp.icsi-ref="%s"%s;+sip.instance="<%s>"`,
contactAddress, icsiRef, extra, instanceID,
)
default:
extra := ""
for _, tag := range registerOptions.ContactExtraTags {
@@ -1076,41 +1086,127 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
}
}
// sessionPAccessNetworkInfo creates a syntactically valid, locally
// administered unicast WLAN node identifier from the already-random SIP
// instance ID. It discloses neither a real BSSID nor subscriber identity, but
// remains stable for every transaction belonging to this IMS registration.
func sessionPAccessNetworkInfo(instanceID string) string {
instanceID = strings.TrimSpace(instanceID)
if instanceID == "" {
return ""
// sipInstanceID uses the standardized GSMA device-instance URI when a valid
// modem identity is available and keeps the generated UUID as the fallback.
func sipInstanceID(identity vowifi.SIMIdentity, fallback string) string {
imei := strings.TrimSpace(identity.IMEI)
if len(imei) == 15 {
valid := true
for _, digit := range imei {
if digit < '0' || digit > '9' {
valid = false
break
}
}
if valid {
return "urn:gsma:imei:" + imei + "-0"
}
}
digest := sha256.Sum256([]byte(instanceID))
digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast
return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6])
return "urn:uuid:" + strings.TrimSpace(fallback)
}
func (session *Session) imsRegisterOptions() vowifi.IMSRegisterOptions {
if session == nil {
return vowifi.IMSRegisterOptions{}
}
return vowifi.ResolveCarrierProfile(session.request.Identity).IMSRegisterOptions
}
func (session *Session) imsUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
if value != "vocat/1" {
return value
}
}
}
if session != nil {
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
if value := strings.TrimSpace(profile.IMSUserAgent); value != "" {
return value
}
}
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
}
return "vocat/1"
}
func (session *Session) imsLogger() *slog.Logger {
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
return session.provider.config.Logger
}
return slog.Default()
}
// resolveSessionPAccessNetworkInfo freezes the selected value when the IMS
// session is created. This prevents a carrier-profile reload from changing
// access identity between REGISTER, SMS MESSAGE and its RP-ACK.
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, instanceID string) string {
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, logger *slog.Logger) string {
if logger == nil {
logger = slog.Default()
}
profile := vowifi.ResolveCarrierProfile(identity)
if profile.PANIEnabled != nil && !*profile.PANIEnabled {
return ""
}
if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil {
return ueProvidedPANI(*configured)
return appendPaniCountry(ueProvidedPANI(*configured), identity, profile, logger)
}
node := strings.ToLower(strings.TrimSpace(profile.PANINode))
if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 {
node = strings.TrimPrefix(sessionPAccessNetworkInfo(instanceID), "IEEE-802.11;i-wlan-node-id=")
node = defaultPANIWLANNode
}
if node == "" {
return ""
}
value := "IEEE-802.11;i-wlan-node-id=" + node
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
value += ";country=" + country
return appendPaniCountry(value, identity, profile, logger)
}
func appendPaniCountry(value string, identity vowifi.SIMIdentity, profile vowifi.CarrierProfile, logger *slog.Logger) string {
value = strings.TrimSpace(value)
if value == "" {
return value
}
return value
parts := strings.Split(value, ";")
for _, parameter := range parts {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(parameter)), "country=") {
return value
}
}
countryMode := strings.ToUpper(strings.TrimSpace(profile.PANICountry))
country := countryMode
if countryMode == "AUTO" {
mcc := strings.TrimSpace(identity.HomeMCC)
if mcc == "" {
mcc = strings.TrimSpace(profile.RouteMCC)
}
country = vowifi.CountryCodeForMCC(mcc)
if country == "" {
if logger == nil {
logger = slog.Default()
}
logger.Error("IMS PANI country code could not be derived",
"category", "ims",
"stage", "pani_country",
"carrier_profile", profile.ID,
"mcc", mcc,
)
return value
}
}
if country == "" {
return value
}
parts = append(parts, "")
copy(parts[2:], parts[1:])
parts[1] = "country=" + country
return strings.Join(parts, ";")
}
// ueProvidedPANI removes the network-provided marker from a profile override.
@@ -1263,6 +1359,7 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
serviceRoutes := splitHeaderValues(response.values("Service-Route"))
registeredContact := ""
smsConfirmed := false
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
instanceLower := strings.ToLower(session.instanceID)
contactURILower := strings.ToLower(fmt.Sprintf(
"sip:%s@%s;transport=%s",
@@ -1270,10 +1367,15 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
session.contactAddress(),
session.transport,
))
contactAddressLower := ""
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
contactAddressLower = strings.ToLower("sip:" + session.contactAddress())
}
for _, contact := range contacts {
lower := strings.ToLower(contact)
matchesThisSession := strings.Contains(lower, instanceLower) ||
strings.Contains(lower, contactURILower)
strings.Contains(lower, contactURILower) ||
(contactAddressLower != "" && strings.Contains(lower, contactAddressLower))
if matchesThisSession {
registeredContact = contact
smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip")
+134 -13
View File
@@ -506,13 +506,8 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
}
func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001"
first := sessionPAccessNetworkInfo(instanceID)
second := sessionPAccessNetworkInfo(instanceID)
if first != second {
t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second)
}
if err := validateTestPANI(first); err != nil {
defaultPANI := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
if err := validateTestPANI(defaultPANI); err != nil {
t.Fatal(err)
}
if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" {
@@ -521,23 +516,149 @@ func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
if got := ueProvidedPANI("network-provided"); got != "" {
t.Fatalf("marker-only PANI = %q, want empty", got)
}
if got := (&Session{paniResolved: true}).pAccessNetworkInfo(); got != "" {
t.Fatalf("explicitly omitted session PANI = %q, want empty", got)
if got := (&Session{pani: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, paniResolved: true}).pAccessNetworkInfo(); got != "IEEE-802.11;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("session PANI = %q, want default WLAN node", got)
}
}
func TestPAccessNetworkInfoUsesDefaultNodeAndConditionalCountry(t *testing.T) {
cases := []struct {
name string
identity vowifi.SIMIdentity
want string
}{
{
name: "standard without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "giffgaff with IPCC PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
want: "IEEE-802.11;country=GB;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "AT&T without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "VOXI without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
got := resolveSessionPAccessNetworkInfo(test.identity, slog.New(slog.NewTextHandler(io.Discard, nil)))
if got != test.want {
t.Fatalf("PANI = %q, want %q", got, test.want)
}
})
}
}
func TestAppendPaniCountryModes(t *testing.T) {
base := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
identity := vowifi.SIMIdentity{HomeMCC: "234"}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{}, slog.Default()); got != base {
t.Fatalf("empty PANI country = %q, want %q", got, base)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "GB"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("fixed PANI country = %q", got)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "AUTO"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("automatic PANI country = %q", got)
}
var logs strings.Builder
logger := slog.New(slog.NewTextHandler(&logs, nil))
got := appendPaniCountry(base, vowifi.SIMIdentity{}, vowifi.CarrierProfile{ID: "test-auto", PANICountry: "AUTO"}, logger)
if got != base || !strings.Contains(logs.String(), "IMS PANI country code could not be derived") {
t.Fatalf("failed automatic PANI country = %q, logs = %q", got, logs.String())
}
}
func TestIMSProfileUserAgentUsesUnifiedHeaderValue(t *testing.T) {
giffgaff := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
}}}
if got := giffgaff.imsUserAgent(); got != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff IMS User-Agent = %q", got)
}
if options := giffgaff.imsRegisterOptions(); options.AllowHeader != nil || options.SupportedHeader != nil {
t.Fatalf("giffgaff REGISTER capability overrides leaked from business headers: %#v", options)
}
standard := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
}}}
if got := standard.imsUserAgent(); got != "vocat/1" {
t.Fatalf("standard IMS User-Agent fallback = %q", got)
}
}
func TestSipInstanceIDUsesGSMAFormWhenIMEIIsAvailable(t *testing.T) {
identity := vowifi.SIMIdentity{IMEI: "353024112557010"}
if got := sipInstanceID(identity, "00000000-0000-4000-8000-000000000001"); got != "urn:gsma:imei:353024112557010-0" {
t.Fatalf("sipInstanceID() = %q", got)
}
if got := sipInstanceID(vowifi.SIMIdentity{IMEI: "not-an-imei"}, "00000000-0000-4000-8000-000000000001"); got != "urn:uuid:00000000-0000-4000-8000-000000000001" {
t.Fatalf("sipInstanceID() fallback = %q", got)
}
}
func TestGSMAContactFormatUsesAddressAndDeviceInstance(t *testing.T) {
session := &Session{
identity: identitySet{user: "234105776448519"},
transport: "tcp",
instanceID: "urn:gsma:imei:353024112557010-0",
}
got := session.buildContact("[2001:db8::1]:49686", vowifi.IMSRegisterOptions{
ContactFormat: vowifi.IMSContactFormatGSMA,
ContactExtraTags: []string{"+g.3gpp.mid-call", "+g.3gpp.smsip"},
})
want := `<sip:[2001:db8::1]:49686>;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel";+g.3gpp.mid-call;+g.3gpp.smsip;+sip.instance="<urn:gsma:imei:353024112557010-0>"`
if got != want {
t.Fatalf("GSMA Contact = %q, want %q", got, want)
}
}
func validateTestPANI(value string) error {
const prefix = "IEEE-802.11;i-wlan-node-id="
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("value %q does not start with %q", value, prefix)
const accessType = "IEEE-802.11"
if !strings.HasPrefix(value, accessType+";") {
return fmt.Errorf("value %q does not start with %q", value, accessType+";")
}
if strings.Contains(strings.ToLower(value), "network-provided") {
return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value)
}
node, err := hex.DecodeString(strings.TrimPrefix(value, prefix))
var nodeValue, country string
for _, parameter := range strings.Split(strings.TrimPrefix(value, accessType+";"), ";") {
key, parameterValue, ok := strings.Cut(parameter, "=")
if !ok {
continue
}
switch strings.ToLower(strings.TrimSpace(key)) {
case "i-wlan-node-id":
nodeValue = strings.TrimSpace(parameterValue)
case "country":
country = strings.TrimSpace(parameterValue)
}
}
if nodeValue == "" {
return fmt.Errorf("i-wlan-node-id is missing: %q", value)
}
node, err := hex.DecodeString(nodeValue)
if err != nil || len(node) != 6 {
return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value)
}
if strings.EqualFold(nodeValue, defaultPANIWLANNode) {
if country != "" && len(country) != 2 {
return fmt.Errorf("country must be an ISO alpha-2 code: %q", value)
}
return nil
}
if node[0]&0x03 != 0x02 {
return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value)
}
+4 -1
View File
@@ -911,7 +911,7 @@ func (session *Session) logInboundSMS(level slog.Level, message string, request
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger
}
base := []any{"device_id", session.request.DeviceID}
base := []any{"category", "sms", "subsystem", "ims", "device_id", session.request.DeviceID}
if request != nil {
base = append(base,
"call_id", strings.TrimSpace(request.value("Call-ID")),
@@ -1091,6 +1091,8 @@ func (session *Session) logOutboundSMS(level slog.Level, message string, attribu
}
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
base := []any{
"category", "sms",
"subsystem", "ims",
"device_id", session.request.DeviceID,
"home_plmn", plmn,
"transport", session.transport,
@@ -1175,6 +1177,7 @@ func (session *Session) sendSIPMessageWith(
lines = append(lines,
"Request-Disposition: no-fork",
"Allow: MESSAGE",
"User-Agent: "+session.imsUserAgent(),
)
if inReplyTo != "" {
lines = append(lines, "In-Reply-To: "+inReplyTo)
+1 -1
View File
@@ -144,7 +144,7 @@ func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context,
}
raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge))
if err != nil {
return AKAResult{}, ErrEC20AKACommand
return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20AKACommand, err)
}
return parseUSIMAuthenticateResponse(raw)
}
@@ -3,6 +3,7 @@ import { CardUiRegular } from "@fluentui/react-icons";
import { Button, Input, Tag, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
import type { CardPolicy } from "../../types";
@@ -141,6 +142,17 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle}
/> : null}
{!wifiCallingOnly ? <CellularIMSPolicyCard
deviceId={deviceId}
iccid={iccid}
enabled={currentPolicy?.cellularImsEnabled ?? false}
managed={currentPolicy?.cellularImsManaged ?? false}
live={operable}
deviceOnline={deviceOnline}
vowifiEnabled={local.vowifiEnabled}
airplaneEnabled={local.airplaneEnabled}
onChanged={onPolicyChanged}
/> : null}
</div>
{!wifiCallingOnly ? <CardPolicyAPN
deviceId={deviceId}
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useState } from "react";
import { apiMessage } from "../../api";
import { useI18n } from "../../lib/i18n";
import { confirmDialog, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { getCellularIMS, setCellularIMS, updateCardPolicy } from "./deviceActions";
interface CellularIMSPolicyCardProps {
deviceId: string;
iccid: string;
enabled: boolean;
managed: boolean;
live: boolean;
deviceOnline: boolean;
vowifiEnabled: boolean;
airplaneEnabled: boolean;
compact?: boolean;
onChanged: () => void | Promise<void>;
}
export function CellularIMSPolicyCard({ deviceId, iccid, enabled, managed, live, deviceOnline, vowifiEnabled, airplaneEnabled, compact, onChanged }: CellularIMSPolicyCardProps) {
const { t } = useI18n();
const [local, setLocal] = useState(enabled);
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const [status, setStatus] = useState<Awaited<ReturnType<typeof getCellularIMS>> | null>(null);
useEffect(() => setLocal(enabled), [enabled, iccid]);
const loadStatus = useCallback(async () => {
if (!live) {
setStatus(null);
return;
}
try {
const status = await getCellularIMS(deviceId);
setStatus(status);
} catch {
setStatus(null);
}
}, [deviceId, live]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
const toggle = async (value: boolean) => {
if (pending || !deviceOnline) return;
const confirmed = await confirmDialog(
<div className="space-y-2">
{value && status?.registered ? (
<p>{t("当前蜂窝 IMS 已正常注册,通常无需强制启用;继续操作仍会改为强制模式。")}</p>
) : null}
{value && !status?.registered && status?.csKnown && status.csRegistered ? (
<p>{t("当前已注册 CS 域,短信可能正在通过 CS 正常工作;强制 IMS 后能否注册取决于运营商、漫游网络和 MBN。")}</p>
) : null}
{value && live && !status ? (
<p>{t("无法读取当前 CS/IMS 状态,继续后将直接尝试应用强制 IMS 配置。")}</p>
) : null}
{!value ? (
<p>{t("关闭此开关只会恢复 MBN/运营商默认 IMS 行为,并不保证蜂窝 IMS 会被禁用。")}</p>
) : null}
{!managed ? <p>{t("确认后 VoCat 将开始按此 ICCID 管理蜂窝 IMS 配置,切换其他卡时不会沿用本卡策略。")}</p> : null}
{vowifiEnabled ? (
<p>{t("当前 VoWiFi 已开启且蜂窝射频关闭;此设置会保存,但蜂窝 IMS 需在关闭 VoWiFi并恢复蜂窝射频后才能注册。")}</p>
) : airplaneEnabled ? (
<p>{t("当前处于飞行模式;此设置会保存,但蜂窝 IMS 需在关闭飞行模式后才能注册。")}</p>
) : null}
{!live ? <p>{t("此卡当前未激活或设备离线,配置将在此卡激活并上线后应用。")}</p> : null}
<p className="font-medium text-amber-700 dark:text-amber-300">
{live
? t("确认后将应用配置并重启模组,蜂窝数据、短信和通话可能短暂断联。")
: t("此卡激活后应用配置时会重启模组,蜂窝数据、短信和通话可能短暂断联。")}
</p>
</div>,
value ? t("确认强制启用蜂窝 IMS 短信") : t("确认恢复默认 IMS 行为"),
{ confirmText: value ? t("强制启用") : t("恢复默认"), type: "warning" },
);
if (!confirmed) return;
const previous = local;
setLocal(value);
setPending(true);
setFailed(false);
try {
if (live) {
const status = await setCellularIMS(deviceId, value);
setStatus(status);
if (status.rebooting) message.success(t("IMS 配置已保存,模组正在重启"));
else if (!status.changed) message.success(t("状态已一致,已跳过重启流程"));
} else {
await updateCardPolicy(iccid, { cellularImsEnabled: value });
message.success(t("IMS 配置已保存,将在此卡激活后生效"));
}
await onChanged();
} catch (error) {
setLocal(previous);
setFailed(true);
message.error(apiMessage(error) || t("蜂窝 IMS 配置失败"));
await onChanged();
} finally {
setPending(false);
}
};
return <PolicySwitchCard
compact={compact}
title={t("强制启用蜂窝 IMS 短信")}
subtitle={compact ? undefined : t("解决部分运营商无法收发短信的问题;该策略根据当前 ICCID/Profile 保存")}
tone="indigo"
checked={local}
disabled={pending || !deviceOnline}
pending={pending}
failed={failed}
onToggle={(value) => void toggle(value)}
/>;
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button, Spinner } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import type { CardPolicy } from "../../types";
@@ -68,7 +69,7 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
) : (
<>
{noteText ? <div className="text-[11px] text-amber-600 dark:text-amber-400">{noteText}</div> : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<PolicySwitchCard
compact
title="VoWiFi"
@@ -87,6 +88,18 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle}
/>
<CellularIMSPolicyCard
compact
deviceId={deviceId}
iccid={iccid}
enabled={policy?.cellularImsEnabled ?? false}
managed={policy?.cellularImsManaged ?? false}
live={mode === "live"}
deviceOnline={deviceOnline}
vowifiEnabled={local.vowifiEnabled}
airplaneEnabled={local.airplaneEnabled}
onChanged={() => { void load(); onPolicyChanged(); }}
/>
</div>
<CardPolicyAPN
deviceId={deviceId}
@@ -28,6 +28,25 @@ export interface CardPolicyUpdate {
apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6";
customPhoneNumber?: string;
cellularImsEnabled?: boolean;
}
export interface CellularIMSStatus {
iccid: string;
desiredEnabled: boolean;
supported: boolean;
configured: boolean;
registered: boolean;
csKnown: boolean;
csRegistered: boolean;
changed: boolean;
rebooting: boolean;
}
export function getCellularIMS(deviceId: string) {
return api<CellularIMSStatus>(`/devices/${deviceId}/cellular-ims`);
}
export function setCellularIMS(deviceId: string, enabled: boolean) {
return api<CellularIMSStatus>(`/devices/${deviceId}/cellular-ims`, { method: "PATCH", body: { enabled } });
}
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
@@ -37,12 +37,28 @@ export function useCardPolicyToggles(source: PolicyFlags | null, impl: PolicyTog
const localRef = useRef(local);
localRef.current = local;
// CardPolicyPanel derives `source` inline, so its object identity changes on
// every render. Depend on the primitive fields instead; otherwise this
// effect updates local state forever and prevents route transitions from
// committing after the card-policy tab has mounted.
const sourceVoWiFiEnabled = source?.vowifiEnabled;
const sourceAirplaneEnabled = source?.airplaneEnabled;
useEffect(() => {
if (!source) return;
setLocal({ vowifiEnabled: source.vowifiEnabled, airplaneEnabled: source.airplaneEnabled });
if (sourceVoWiFiEnabled === undefined || sourceAirplaneEnabled === undefined) return;
setLocal((current) => {
if (
current.vowifiEnabled === sourceVoWiFiEnabled &&
current.airplaneEnabled === sourceAirplaneEnabled
) return current;
return {
vowifiEnabled: sourceVoWiFiEnabled,
airplaneEnabled: sourceAirplaneEnabled,
};
});
setVowifiFailed(false);
setAirplaneFailed(false);
}, [source]);
}, [sourceVoWiFiEnabled, sourceAirplaneEnabled]);
async function toggle(
field: Field,
+10 -6
View File
@@ -11,12 +11,13 @@ import { message } from "../ui/message";
type RetentionMode = LoggingSettings["mode"];
// 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。
export function LogRetentionCard() {
export function LogRetentionCard({ refreshKey = 0 }: { refreshKey?: number }) {
const { t } = useI18n();
const [mode, setMode] = useState<RetentionMode>("unlimited");
const [count, setCount] = useState(10000);
const [days, setDays] = useState(30);
const [storedLogs, setStoredLogs] = useState(0);
const [maxLogs, setMaxLogs] = useState(10000);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -25,6 +26,7 @@ export function LogRetentionCard() {
setCount(data.count);
setDays(data.days);
setStoredLogs(data.storedLogs);
setMaxLogs(data.maxLogs || 10000);
}, []);
useEffect(() => {
@@ -41,14 +43,14 @@ export function LogRetentionCard() {
return () => {
cancelled = true;
};
}, [apply]);
}, [apply, refreshKey]);
const save = useCallback(async () => {
setSaving(true);
try {
const data = await updateLoggingSettings({
mode,
count: Math.max(1, Math.trunc(count) || 1),
count: Math.min(maxLogs, Math.max(1, Math.trunc(count) || 1)),
days: Math.max(1, Math.trunc(days) || 1),
});
apply(data);
@@ -58,7 +60,7 @@ export function LogRetentionCard() {
} finally {
setSaving(false);
}
}, [mode, count, days, apply]);
}, [mode, count, days, maxLogs, apply]);
const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseInt(event.target.value, 10);
@@ -78,7 +80,7 @@ export function LogRetentionCard() {
className="w-32"
disabled={loading}
options={[
{ value: "unlimited", label: t("不限制") },
{ value: "unlimited", label: t("最多 10000 条") },
{ value: "count", label: t("按条数") },
{ value: "days", label: t("按天数") },
]}
@@ -88,6 +90,7 @@ export function LogRetentionCard() {
<Input
type="number"
min={1}
max={maxLogs}
value={count === 0 ? "" : count}
onChange={onNumber(setCount)}
disabled={loading}
@@ -110,8 +113,9 @@ export function LogRetentionCard() {
</label>
) : null}
<span className="text-sm text-gray-400">
{t("当前已存储")} {storedLogs} {t("条")}
{t("当前已存储")} {storedLogs} / {maxLogs} {t("条")}
</span>
<span className="text-xs text-gray-400">{t("达到上限后自动删除最旧日志")}</span>
<div className="flex-1" />
<Button
size="small"
+16
View File
@@ -498,6 +498,22 @@ export const EN_DICT: Record<string, string> = {
"连接中断,正在尝试重连…": "Connection lost, reconnecting…",
: "Logs exported",
"查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search",
: "Device & Service Logs",
"记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码":
"Hardware, network registration, WiFi Calling, SMS, calls, and user operations. Sensitive identities are automatically redacted.",
: "Category",
: "All Services",
: "Hardware & Modem",
: "Network Registration",
: "Calls",
: "User Operations",
: "System",
"最多 10000 条": "Up to 10,000",
: "The oldest logs are automatically removed at the limit",
"此操作会永久删除服务端保存的全部日志,无法恢复。": "This permanently deletes all logs stored on the server and cannot be undone.",
"确认清空日志?": "Clear all logs?",
: "Logs cleared",
: "Failed to clear logs",
: "Resume",
: "Pause",
: "Connected",
+113 -26
View File
@@ -16,11 +16,13 @@ import { Switch } from "../components/ui/Switch";
import { Select } from "../components/ui/Select";
import { Input } from "../components/ui/Input";
import { message } from "../components/ui/message";
import { confirmDialog } from "../components/ui/MessageBox";
import { LogRetentionCard } from "../components/logs/LogRetentionCard";
const MAX_LOGS = 1000;
type Level = "all" | "debug" | "info" | "warn" | "error";
type Category = "all" | "hardware" | "network" | "vowifi" | "sms" | "call" | "operation" | "system";
const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "all", label: "全部" },
@@ -30,6 +32,17 @@ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "error", label: "ERROR" },
];
const CATEGORY_OPTIONS: { value: Category; label: string }[] = [
{ value: "all", label: "全部业务" },
{ value: "hardware", label: "硬件与模块" },
{ value: "network", label: "驻网" },
{ value: "vowifi", label: "WiFi Calling" },
{ value: "sms", label: "短信" },
{ value: "call", label: "通话" },
{ value: "operation", label: "用户操作" },
{ value: "system", label: "系统错误" },
];
function levelColor(level: string): string {
switch (level.toLowerCase()) {
case "debug":
@@ -52,6 +65,42 @@ function fieldsText(fields: LogEntry["fields"]): string {
return typeof fields === "string" ? fields : JSON.stringify(fields);
}
function logFields(entry: LogEntry): Record<string, unknown> {
return entry.fields && typeof entry.fields === "object" ? entry.fields : {};
}
function logCategory(entry: LogEntry): Exclude<Category, "all"> {
const explicit = String(logFields(entry).category ?? "").toLowerCase();
if (CATEGORY_OPTIONS.some((item) => item.value === explicit && item.value !== "all")) {
return explicit as Exclude<Category, "all">;
}
const text = `${entry.message} ${fieldsText(entry.fields)}`.toLowerCase();
if (/\bsms\b|短信|tpdu|rp-data|rpdu/.test(text)) return "sms";
if (/incoming call|\bcall\b|invite|来电|通话/.test(text)) return "call";
if (/vowifi|wi-?fi calling|\bims\b|\bike\b|epdg|ipsec/.test(text)) return "vowifi";
if (/registration|operator|network|驻网|注册网络/.test(text)) return "network";
if (/device|modem|hardware|sim|uicc|esim|qmi|串口|模块|设备/.test(text)) return "hardware";
if (/operation|audit|setting|操作/.test(text)) return "operation";
return "system";
}
function categoryColor(category: Exclude<Category, "all">): string {
switch (category) {
case "hardware": return "bg-cyan-500/15 text-cyan-300";
case "network": return "bg-emerald-500/15 text-emerald-300";
case "vowifi": return "bg-sky-500/15 text-sky-300";
case "sms": return "bg-violet-500/15 text-violet-300";
case "call": return "bg-pink-500/15 text-pink-300";
case "operation": return "bg-amber-500/15 text-amber-300";
default: return "bg-gray-500/20 text-gray-300";
}
}
function isHTTPAccessLog(entry: LogEntry): boolean {
return entry.message.trim().toLowerCase() === "http request" ||
String(logFields(entry).category ?? "").toLowerCase() === "http_access";
}
// Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp.
function displayTime(time: string): string {
try {
@@ -71,8 +120,11 @@ export default function LogsPage() {
const [paused, setPaused] = useState(false);
const [autoTail, setAutoTail] = useState(true);
const [level, setLevel] = useState<Level>("all");
const [category, setCategory] = useState<Category>("all");
const [search, setSearch] = useState("");
const [connError, setConnError] = useState("");
const [clearing, setClearing] = useState(false);
const [retentionRefreshKey, setRetentionRefreshKey] = useState(0);
const esRef = useRef<EventSource | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
@@ -80,6 +132,7 @@ export default function LogsPage() {
const levelRef = useRef<Level>("all");
const appendLog = useCallback((entry: LogEntry) => {
if (isHTTPAccessLog(entry)) return;
setLogs((prev) => {
const next = [...prev, entry];
return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
@@ -118,7 +171,7 @@ export default function LogsPage() {
try {
const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500");
const list = Array.isArray(res) ? res : (res?.logs ?? []);
setLogs(list.slice(-MAX_LOGS));
setLogs(list.filter((entry) => !isHTTPAccessLog(entry)).slice(-MAX_LOGS));
} catch {
/* 历史回填失败不阻塞实时流 */
} finally {
@@ -164,13 +217,34 @@ export default function LogsPage() {
}
}, [connect]);
const clearLogs = useCallback(() => setLogs([]), []);
const clearLogs = useCallback(async () => {
const confirmed = await confirmDialog(
t("此操作会永久删除服务端保存的全部日志,无法恢复。"),
t("确认清空日志?"),
{ type: "warning", confirmText: t("清空"), cancelText: t("取消") },
);
if (!confirmed) return;
setClearing(true);
try {
await api<{ cleared: boolean; deleted: number }>("/logs/history", { method: "DELETE" });
setLogs([]);
setRetentionRefreshKey((value) => value + 1);
message.success(t("日志已清空"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("清空日志失败"));
} finally {
setClearing(false);
}
}, [t]);
const filtered = useMemo(() => {
let list = logs;
if (level !== "all") {
list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase());
}
if (category !== "all") {
list = list.filter((entry) => logCategory(entry) === category);
}
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
@@ -181,14 +255,14 @@ export default function LogsPage() {
);
}
return list;
}, [logs, level, search]);
}, [logs, level, category, search]);
const exportLogs = useCallback(() => {
const text = filtered
.map((v) => {
const time = new Date(v.time).toLocaleString();
const fields = v.fields ? ` ${fieldsText(v.fields)}` : "";
return `[${time}] ${v.level.toUpperCase().padEnd(5)} ${v.caller ?? ""} ${v.message}${fields}`;
return `[${time}] ${v.level.toUpperCase().padEnd(5)} [${logCategory(v)}] ${v.caller ?? ""} ${v.message}${fields}`;
})
.join("\n");
const blob = new Blob([text], { type: "text/plain" });
@@ -204,8 +278,8 @@ export default function LogsPage() {
return (
<div className="max-w-7xl mx-auto">
<PageHeader
title={t("实时日志")}
subtitle={t("查看系统运行日志,支持过滤和搜索")}
title={t("设备与业务日志")}
subtitle={t("记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码")}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -216,7 +290,7 @@ export default function LogsPage() {
>
{paused ? t("继续") : t("暂停")}
</Button>
<Button onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
<Button loading={clearing} onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
{t("清空")}
</Button>
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}>
@@ -255,6 +329,13 @@ export default function LogsPage() {
className="w-full sm:w-40"
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
/>
<Select
value={category}
onChange={(v) => setCategory(v as Category)}
placeholder={t("业务分类")}
className="w-full sm:w-44"
options={CATEGORY_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -279,7 +360,7 @@ export default function LogsPage() {
</div>
</div>
<LogRetentionCard />
<LogRetentionCard refreshKey={retentionRefreshKey} />
<div className="ui-card overflow-hidden">
<div
@@ -291,24 +372,30 @@ export default function LogsPage() {
{loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")}
</div>
) : null}
{filtered.map((entry, i) => (
<div key={i} className="py-0.5 hover:bg-white/5 px-2 -mx-2 rounded whitespace-nowrap">
<span className="text-gray-500">[{displayTime(entry.time)}]</span>
<span className={cx("font-bold ml-1.5", levelColor(entry.level))}>
{entry.level.toUpperCase()}
</span>
<span
className="text-indigo-400 inline-block max-w-48 truncate align-bottom ml-1.5"
title={entry.caller ?? ""}
>
{entry.caller ?? ""}
</span>
<span className="text-gray-100 ml-1.5">{entry.message}</span>
{entry.fields ? (
<span className="text-amber-300/70 ml-1.5">{fieldsText(entry.fields)}</span>
) : null}
</div>
))}
{filtered.map((entry, i) => {
const entryCategory = logCategory(entry);
const fields = logFields(entry);
const hasRawError = fields.raw_error !== undefined || fields.error !== undefined || fields.raw_response !== undefined;
return (
<div key={`${entry.time}-${i}`} className="border-b border-white/5 px-2 py-2 -mx-2 last:border-0 hover:bg-white/5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-gray-500">[{displayTime(entry.time)}]</span>
<span className={cx("font-bold", levelColor(entry.level))}>{entry.level.toUpperCase()}</span>
<span className={cx("rounded px-1.5 py-0.5 text-[11px]", categoryColor(entryCategory))}>
{t(CATEGORY_OPTIONS.find((item) => item.value === entryCategory)?.label ?? "系统错误")}
</span>
{entry.caller ? <span className="max-w-48 truncate text-indigo-400" title={entry.caller}>{entry.caller}</span> : null}
<span className="break-words text-gray-100">{entry.message}</span>
</div>
{entry.fields ? (
<pre className={cx(
"mt-1 whitespace-pre-wrap break-all pl-2 text-xs leading-5",
hasRawError ? "border-l-2 border-red-500/60 text-red-200" : "text-amber-300/70",
)}>{typeof entry.fields === "string" ? entry.fields : JSON.stringify(entry.fields, null, 2)}</pre>
) : null}
</div>
);
})}
</div>
</div>
</div>
+4 -1
View File
@@ -273,6 +273,8 @@ export interface CardPolicy {
apn?: string;
ipVersion?: string;
customPhoneNumber?: string;
cellularImsEnabled: boolean;
cellularImsManaged: boolean;
source?: string;
createdAt?: string;
updatedAt?: string;
@@ -432,12 +434,13 @@ export interface SecuritySettings {
clientAllowed: boolean;
}
// 运行日志保留策略:默认不限制,可按条数或天数限制。
// 运行日志保留策略:全局硬上限 10000 条,可配置更严格的条数或天数限制。
export interface LoggingSettings {
mode: "unlimited" | "count" | "days";
count: number;
days: number;
storedLogs: number;
maxLogs: number;
}
export interface SystemInfo {