Compare commits

..
7 Commits
Author SHA1 Message Date
4a39ed3d31 feat: add per-card cellular IMS SMS policy (#77)
Co-authored-by: geekouc <[email protected]>
2026-08-22 02:37:35 +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() { 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) logger := slog.New(logs)
args := os.Args[1:] args := os.Args[1:]
@@ -206,7 +206,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
} }
cardReaders := pcsc.New() 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 { if err != nil {
return fmt.Errorf("create device manager: %w", err) 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()) pollContext, cancelPolling := context.WithCancel(context.Background())
defer cancelPolling() defer cancelPolling()
go pollDeviceSnapshots(pollContext, logger, database, deviceManager) go pollDeviceSnapshots(pollContext, deviceLogger, database, deviceManager)
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager) go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
go collectCellularTraffic(pollContext, logger, database) go collectCellularTraffic(pollContext, logger, database)
go persistLogsToStore(pollContext, logger, logs, database) go persistLogsToStore(pollContext, logger, logs, database)
@@ -495,26 +496,7 @@ func restoreConfiguredCellularData(
if err != nil { if err != nil {
continue continue
} }
networkRequest := device.NetworkRequest{ networkRequest := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
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
}
}
}
}
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second) dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest) _, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
cancel() 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( func disableAllDeveloperCellularData(
ctx context.Context, ctx context.Context,
logger *slog.Logger, logger *slog.Logger,
@@ -640,7 +656,7 @@ func configureVoWiFiRuntime(
Devices: mapper, Devices: mapper,
} }
manager := vowifiruntime.New(vowifiruntime.Options{ manager := vowifiruntime.New(vowifiruntime.Options{
Logger: logger, Logger: logger.With("category", "vowifi"),
OnState: projector.Save, OnState: projector.Save,
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) { Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
deviceConfig, err := database.Device(factoryContext, deviceID) 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()) _ = manager.Close(context.Background())
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err) return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
} }
@@ -695,8 +722,55 @@ func configureVoWiFiRuntime(
const ( const (
vowifiStartupRadioAttempts = 3 vowifiStartupRadioAttempts = 3
vowifiStartupRadioDelay = time.Second 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 { type flightModeSetter interface {
SetFlight(context.Context, string, bool) (device.FlightResult, error) SetFlight(context.Context, string, bool) (device.FlightResult, error)
} }
@@ -717,7 +791,7 @@ func protectVoWiFiStartupRadioWithRetry(
physicalID string, physicalID string,
attempts int, attempts int,
delay time.Duration, delay time.Duration,
) error { ) error {
var lastErr error var lastErr error
for attempt := 0; attempt < attempts; attempt++ { for attempt := 0; attempt < attempts; attempt++ {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
@@ -759,14 +833,15 @@ func newVoWiFiOrchestrator(
if apn == "" { if apn == "" {
apn = "ims" apn = "ims"
} }
vowifiLogger := logger.With("category", "vowifi", "device_id", deviceConfig.ID)
tunnelProvider, err := ike.NewProvider(ike.Config{ tunnelProvider, err := ike.NewProvider(ike.Config{
APN: apn, Logger: logger, AutoProposalFallback: true, APN: apn, Logger: vowifiLogger, AutoProposalFallback: true,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err) return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
} }
imsProvider, err := ims.NewProvider(adapter, ims.Config{ imsProvider, err := ims.NewProvider(adapter, ims.Config{
Logger: logger, Logger: vowifiLogger,
// Carrier-specific transport and SMSC defaults live in the shared data // Carrier-specific transport and SMSC defaults live in the shared data
// profile. Prefer network-provided P-CSCF hints, then safely try the // profile. Prefer network-provided P-CSCF hints, then safely try the
// alternate transport only if no SIP response was observed. // alternate transport only if no SIP response was observed.
@@ -983,6 +1058,10 @@ func persistLogsToStore(
if !ok { if !ok {
return return
} }
if loghub.IsHTTPAccessEntry(entry) {
continue
}
entry = loghub.SanitizeEntry(entry)
var fields json.RawMessage var fields json.RawMessage
if len(entry.Fields) > 0 { if len(entry.Fields) > 0 {
if raw, err := json.Marshal(entry.Fields); err == nil { if raw, err := json.Marshal(entry.Fields); err == nil {
@@ -1097,7 +1176,7 @@ func enforceDefaultSafeCardPolicy(
} }
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{ if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default", IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
}); err != nil { }); err != nil {
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err) logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
return return
@@ -1130,6 +1209,10 @@ func reconcileCardPolicies(
vowifiManager *vowifiruntime.Manager, vowifiManager *vowifiruntime.Manager,
) { ) {
observedCards := make(map[string]string) 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() { reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx) policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil { if policyListErr == nil {
@@ -1176,6 +1259,38 @@ func reconcileCardPolicies(
if policyErr != nil { if policyErr != nil {
continue 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) { if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
policy.AirplaneEnabled = true policy.AirplaneEnabled = true
policy.NetworkEnabled = false policy.NetworkEnabled = false
@@ -1211,6 +1326,9 @@ func reconcileCardPolicies(
} }
switch { switch {
case stateErr != nil || !state.Enabled: case stateErr != nil || !state.Enabled:
if shouldDelayWiFi410VoWiFi(config.DeviceType, time.Now(), wifi410StartupNotBefore) {
continue
}
_, _ = vowifiManager.RequestEnabled(config.ID, true) _, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid): case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID) _, _ = 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 const slot uint8 = 1
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid) 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 { if err != nil {
_ = session.Close() _ = session.Close()
return nil, fmt.Errorf("%w: %v", errNoEUICC, err) 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)) seen := make(map[string]struct{}, len(candidates))
type discoveryEvent struct {
connected bool
candidate modem.Candidate
}
events := make([]discoveryEvent, 0)
manager.mu.Lock() manager.mu.Lock()
for _, candidate := range candidates { for _, candidate := range candidates {
if strings.TrimSpace(candidate.ID) == "" { if strings.TrimSpace(candidate.ID) == "" {
@@ -218,8 +223,12 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
candidate: candidate, candidate: candidate,
discovered: true, discovered: true,
} }
events = append(events, discoveryEvent{connected: true, candidate: candidate})
continue continue
} }
if !state.discovered {
events = append(events, discoveryEvent{connected: true, candidate: candidate})
}
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() { if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
state.resetClientOnLock = true state.resetClientOnLock = true
} }
@@ -231,10 +240,28 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if _, ok := seen[id]; ok { if _, ok := seen[id]; ok {
continue continue
} }
if state.discovered {
events = append(events, discoveryEvent{candidate: state.candidate})
}
state.discovered = false state.discovered = false
stale = append(stale, state) stale = append(stale, state)
} }
manager.mu.Unlock() 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 { for _, state := range stale {
state.opMu.Lock() state.opMu.Lock()
@@ -382,6 +409,11 @@ func (manager *Manager) setResult(
return return
} }
previousError := state.lastError previousError := state.lastError
var previousSnapshot *Snapshot
if state.snapshot != nil {
value := *state.snapshot
previousSnapshot = &value
}
if snapshot != nil { if snapshot != nil {
value := *snapshot value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...) value.Warnings = append([]string(nil), snapshot.Warnings...)
@@ -394,6 +426,13 @@ func (manager *Manager) setResult(
state.lastError = "" state.lastError = ""
} }
shouldLog := err != nil && manager.logger != nil && previousError != err.Error() 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 backend := state.backend
hardwareKind := state.candidate.HardwareKind hardwareKind := state.candidate.HardwareKind
manager.mu.Unlock() manager.mu.Unlock()
@@ -406,6 +445,21 @@ func (manager *Manager) setResult(
"error", HardwareErrorDetail(err), "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 { func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
+57 -1
View File
@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
) )
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error { 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) { 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 { 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 { if openErr != nil {
return fmt.Errorf("open QMI UIM logical channel: %w", openErr) 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 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) { func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
qmiMode, modeErr := session.GetOperatingMode(ctx) qmiMode, modeErr := session.GetOperatingMode(ctx)
+23 -3
View File
@@ -9,9 +9,9 @@ import (
"time" "time"
) )
// Entry is the stable, secret-neutral representation exposed by the log API. // Entry is the stable, centrally-redacted representation exposed by the log
// Callers remain responsible for never adding credentials or keying material // API. The Hub sanitizes both the downstream handler and the captured entry so
// to slog attributes. // diagnostic logs can be safely exported by users.
type Entry struct { type Entry struct {
Time time.Time `json:"time"` Time time.Time `json:"time"`
Level string `json:"level"` 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 { func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
record = sanitizeRecord(record)
err := h.next.Handle(ctx, record) err := h.next.Handle(ctx, record)
fields := make(map[string]any) fields := make(map[string]any)
for _, attr := range h.attrs { 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 { func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
attrs = sanitizeAttrs(attrs)
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...) nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
return &Hub{ return &Hub{
next: h.next.WithAttrs(attrs), next: h.next.WithAttrs(attrs),
@@ -180,6 +182,24 @@ func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) {
return channel, cancel 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) { func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve() attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) { if attr.Equal(slog.Attr{}) {
+74
View File
@@ -1,9 +1,12 @@
package loghub package loghub
import ( import (
"bytes"
"context" "context"
"errors"
"io" "io"
"log/slog" "log/slog"
"strings"
"testing" "testing"
"time" "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) { func TestHubSubscription(t *testing.T) {
hub := New(slog.NewTextHandler(io.Discard, nil), 100) hub := New(slog.NewTextHandler(io.Discard, nil), 100)
entries, cancel := hub.Subscribe(1) entries, cancel := hub.Subscribe(1)
@@ -47,3 +95,29 @@ func TestHubSubscription(t *testing.T) {
t.Fatal("timed out waiting for log entry") 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 ( import (
"context" "context"
"log/slog"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -21,6 +22,20 @@ func (s *Server) recordAudit(
outcome string, outcome string,
remoteAddr 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 { if s.store == nil {
return return
} }
@@ -34,7 +49,24 @@ func (s *Server) recordAudit(
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}) })
if err != nil { 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 { 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()) writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
return true return true
} }
@@ -156,6 +161,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
return true return true
} }
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") { 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") writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
return true return true
} }
+23 -7
View File
@@ -98,11 +98,21 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
if notification.Time.IsZero() { if notification.Time.IsZero() {
notification.Time = time.Now().UTC() 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) dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) { if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
if s.logger != nil { 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 return
} }
@@ -137,7 +147,7 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
} }
if err := sendCallNotification(destCtx, channel, config, notification); err != nil { if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
if s.logger != 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) calls := parseCLCC(response)
for _, call := range calls { for _, call := range calls {
direction, _ := call["direction"].(int) if isIncomingVoiceCLCC(call) {
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) {
caller, _ := call["number"].(string) caller, _ := call["number"].(string)
if caller == "" { if caller == "" {
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" "strings"
"testing" "testing"
"time" "time"
"vocat/internal/modem"
) )
func TestIncomingCallNotificationTextFormatting(t *testing.T) { 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) { func TestRenderCallWebhookTemplate(t *testing.T) {
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC) now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
message := IncomingCallNotification{ message := IncomingCallNotification{
+186 -5
View File
@@ -56,6 +56,11 @@ type DeviceController interface {
ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error) 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 { type deviceConfigPayload struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` 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) { if errors.Is(policyErr, store.ErrNotFound) {
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default", IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
}) })
} }
if policyErr != nil { if policyErr != nil {
@@ -614,6 +619,11 @@ func (s *Server) handleDevicePath(
return true return true
} }
return s.handleAPNProfiles(w, r, physicalID) 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": case "network/public-ip":
if !s.requirePhysicalDevice(w, physicalPresent) { if !s.requirePhysicalDevice(w, physicalPresent) {
return true return true
@@ -1002,6 +1012,11 @@ func (s *Server) handleVoWiFiReconnect(
} }
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) { 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 { switch {
case errors.Is(err, vowifiruntime.ErrNotRegistered): case errors.Is(err, vowifiruntime.ErrNotRegistered):
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime") 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): case errors.Is(err, vowifi.ErrNotRunning):
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running") writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
default: default:
s.logger.Warn("VoWiFi action rejected", "error", err)
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error()) 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 += "\n"
} }
text += commandErr.Final 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{ writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{ "data": map[string]any{
"response": text, "response": text,
@@ -1378,6 +1399,164 @@ func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physi
return true 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( func (s *Server) handleCellularData(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, 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) { 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 { switch {
case errors.Is(err, device.ErrNotFound): case errors.Is(err, device.ErrNotFound):
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present") 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): case errors.Is(err, context.Canceled):
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled") writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
default: 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") 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 { type esimAIDCaptureController struct {
fakeDeviceController fakeDeviceController
switchAID string 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) { 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 return
} }
limit, err := strconv.Atoi(r.URL.Query().Get("lines")) 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). // backs the live stream).
entries := []loghub.Entry{} entries := []loghub.Entry{}
if s.store != nil { 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 { if err != nil {
s.writeStoreError(w, err) s.writeStoreError(w, err)
return return
@@ -179,11 +198,17 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
if storedLogLevel(event.Level) < minimum { if storedLogLevel(event.Level) < minimum {
continue continue
} }
entry := storedLogToEntry(event) entry := loghub.SanitizeEntry(storedLogToEntry(event))
if loghub.IsHTTPAccessEntry(entry) {
continue
}
if search != "" && !storedLogContains(entry, search) { if search != "" && !storedLogContains(entry, search) {
continue continue
} }
entries = append(entries, entry) entries = append(entries, entry)
if len(entries) == limit {
break
}
} }
// ListLogEvents is newest-first; present chronologically. // ListLogEvents is newest-first; present chronologically.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { 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 { if !ok {
return return
} }
if logLevel(entry.Level) < minimum { entry = loghub.SanitizeEntry(entry)
if loghub.IsHTTPAccessEntry(entry) || logLevel(entry.Level) < minimum {
continue continue
} }
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil { if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
@@ -308,7 +334,7 @@ func logLevel(value string) slog.Level {
return slog.LevelError return slog.LevelError
case "warn", "warning": case "warn", "warning":
return slog.LevelWarn return slog.LevelWarn
case "debug": case "debug", "all", "":
return slog.LevelDebug return slog.LevelDebug
default: default:
return slog.LevelInfo 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 { if config.Count < 1 {
config.Count = 10000 config.Count = 10000
} }
if config.Count > store.MaxLogEvents {
config.Count = store.MaxLogEvents
}
if config.Days < 1 { if config.Days < 1 {
config.Days = 30 config.Days = 30
} }
return config, nil 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 { func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
config := defaultLoggingConfig() config := defaultLoggingConfig()
setting, err := s.store.AppSetting(ctx, loggingSettingKey) setting, err := s.store.AppSetting(ctx, loggingSettingKey)
@@ -64,13 +68,17 @@ func (s *Server) applyLogRetention(ctx context.Context) error {
switch config.Mode { switch config.Mode {
case "days": case "days":
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour) 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 return err
case "count": case "count":
_, err := s.store.PruneLogEventsToCount(ctx, config.Count) _, err := s.store.PruneLogEventsToCount(ctx, config.Count)
return err return err
default: 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, "count": config.Count,
"days": config.Days, "days": config.Days,
"stored_logs": stored, "stored_logs": stored,
"max_logs": store.MaxLogEvents,
}, },
}) })
case http.MethodPut: case http.MethodPut:
@@ -152,6 +161,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
"count": config.Count, "count": config.Count,
"days": config.Days, "days": config.Days,
"stored_logs": stored, "stored_logs": stored,
"max_logs": store.MaxLogEvents,
}, },
}) })
default: 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) { func TestLoginLockoutViaHTTP(t *testing.T) {
app := newTestApplication(t) app := newTestApplication(t)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
+47 -14
View File
@@ -160,7 +160,7 @@ func New(options Options) (*Server, error) {
mux.HandleFunc("/", server.handleSPA) mux.HandleFunc("/", server.handleSPA)
server.handler = server.recoverPanics( server.handler = server.recoverPanics(
server.securityHeaders(server.accessControl(server.logRequests(mux))), server.securityHeaders(server.accessControl(server.logUserOperation(mux))),
) )
return server, nil return server, nil
} }
@@ -557,16 +557,14 @@ func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool
return false return false
} }
type statusWriter struct { type operationStatusWriter struct {
http.ResponseWriter http.ResponseWriter
status int status int
} }
func (w *statusWriter) Unwrap() http.ResponseWriter { func (w *operationStatusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
return w.ResponseWriter
}
func (w *statusWriter) WriteHeader(status int) { func (w *operationStatusWriter) WriteHeader(status int) {
if w.status != 0 { if w.status != 0 {
return return
} }
@@ -574,25 +572,60 @@ func (w *statusWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status) 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now() if !strings.HasPrefix(r.URL.Path, "/api/") ||
writer := &statusWriter{ResponseWriter: w} 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) next.ServeHTTP(writer, r)
status := writer.status status := writer.status
if status == 0 { if status == 0 {
status = http.StatusOK status = http.StatusOK
} }
s.logger.Info( level := slog.LevelInfo
"http request", outcome := "success"
"method", r.Method, message := "user operation completed"
"path", r.URL.Path, 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, "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 { func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
+28
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"io/fs" "io/fs"
"log/slog"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
@@ -18,9 +19,36 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"vocat/internal/auth" "vocat/internal/auth"
"vocat/internal/loghub"
"vocat/internal/store" "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 { type testApplication struct {
server *httptest.Server server *httptest.Server
client *http.Client 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)}) writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut: case http.MethodPut:
var request struct { var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"` VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"` AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"` APN *string `json:"apn"`
IPVersion *string `json:"ip_version"` IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"` CustomPhoneNumber *string `json:"custom_phone_number"`
CellularIMSEnabled *bool `json:"cellular_ims_enabled"`
} }
if err := s.decodeJSON(w, r, &request); err != nil { if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return return
} }
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil && 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( writeError(
w, w,
http.StatusBadRequest, http.StatusBadRequest,
@@ -1464,6 +1466,10 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
} }
policy.CustomPhoneNumber = phoneNumber policy.CustomPhoneNumber = phoneNumber
} }
if request.CellularIMSEnabled != nil {
policy.CellularIMSEnabled = *request.CellularIMSEnabled
policy.CellularIMSManaged = true
}
if request.VoWiFiEnabled != nil { if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled 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 { func defaultCardPolicy(iccid string) store.CardPolicy {
return store.CardPolicy{ return store.CardPolicy{
ICCID: strings.TrimSpace(iccid), ICCID: strings.TrimSpace(iccid),
VoWiFiEnabled: true, VoWiFiEnabled: true,
AirplaneEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", IPVersion: "IPV4V6",
Source: "default", Source: "default",
CellularIMSManaged: true,
} }
} }
@@ -1790,14 +1797,16 @@ func normalizeCustomPhoneNumber(value string) (string, error) {
func cardPolicyResponse(policy store.CardPolicy) map[string]any { func cardPolicyResponse(policy store.CardPolicy) map[string]any {
response := map[string]any{ response := map[string]any{
"iccid": policy.ICCID, "iccid": policy.ICCID,
"network_enabled": false, "network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled, "vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled, "airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN, "apn": policy.APN,
"ip_version": policy.IPVersion, "ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber, "custom_phone_number": policy.CustomPhoneNumber,
"source": policy.Source, "cellular_ims_enabled": policy.CellularIMSEnabled,
"cellular_ims_managed": policy.CellularIMSManaged,
"source": policy.Source,
} }
if !policy.CreatedAt.IsZero() { if !policy.CreatedAt.IsZero() {
response["created_at"] = policy.CreatedAt response["created_at"] = policy.CreatedAt
+10 -1
View File
@@ -668,7 +668,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
policy := response["data"].(map[string]any) policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" || if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true || 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) t.Fatalf("default policy = %#v", policy)
} }
@@ -681,6 +682,14 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body) 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) response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any) policy = response["data"].(map[string]any)
if policy["custom_phone_number"] != "+8613800138000" { 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 { if sendErr != nil {
data["retry_safe"] = false data["retry_safe"] = false
if result.PartsAccepted > 0 { 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." 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}) writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
return return
} }
s.logger.Warn( s.logger.Warn(
"SMS submission failed after modem interaction", "SMS submission failed after modem interaction",
"category", "sms",
"event", "sms.submission",
"device_id", request.DeviceID, "device_id", request.DeviceID,
"peer", request.Phone,
"transport", "cellular_at",
"parts_attempted", result.PartsAttempted, "parts_attempted", result.PartsAttempted,
"parts_accepted", result.PartsAccepted, "parts_accepted", result.PartsAccepted,
"error", sendErr, "raw_error", sendErr,
) )
writeJSON(w, http.StatusBadGateway, map[string]any{ writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{ "error": apiError{
@@ -390,6 +400,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
return return
} }
if !result.AllPartsAccepted { 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{ writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{ "error": apiError{
Code: "sms_submission_unconfirmed", Code: "sms_submission_unconfirmed",
@@ -399,6 +415,11 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
}) })
return 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}) 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), "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
} }
if sendErr != nil { 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["retry_safe"] = false
data["warning"] = sendErr.Error() data["warning"] = sendErr.Error()
if result.PartsAccepted == 0 { if result.PartsAccepted == 0 {
@@ -489,6 +516,11 @@ func (s *Server) writeIMSSMSSendResult(
} }
} }
if !result.AllPartsAccepted && result.PartsAccepted == 0 { 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{ writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{ "error": apiError{
Code: "ims_sms_submission_unconfirmed", Code: "ims_sms_submission_unconfirmed",
@@ -498,6 +530,19 @@ func (s *Server) writeIMSSMSSendResult(
}) })
return 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}) 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, "delivery_status": message.DeliveryStatus,
"data_coding_scheme": message.DataCodingScheme, "data_coding_scheme": message.DataCodingScheme,
}) })
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{ saved, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
MessageID: messageID, MessageID: messageID,
DeviceID: config.ID, DeviceID: config.ID,
ModemIMEI: modemIMEI, ModemIMEI: modemIMEI,
@@ -670,7 +715,14 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
Extra: extra, Extra: extra,
}) })
if saveErr != nil { 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") writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
return 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") 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 { if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if version != 19 { if version != schemaVersion {
t.Fatalf("schema version = %d, want 19", version) t.Fatalf("schema version = %d, want %d", version, schemaVersion)
} }
for _, column := range []string{ for _, column := range []string{
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center", "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 { if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if version != 19 { if version != schemaVersion {
t.Fatalf("schema version = %d, want 19", version) 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" { if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("log filter result = %+v, %v", logs, err) 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( auditDeleted, logDeleted, err := database.PruneEvents(
ctx, ctx,
old.Add(time.Minute), old.Add(time.Minute),
+56 -2
View File
@@ -9,6 +9,10 @@ import (
"time" "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) { func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
value.Action = strings.TrimSpace(value.Action) value.Action = strings.TrimSpace(value.Action)
if 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) { 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)) value.Level = strings.ToLower(strings.TrimSpace(value.Level))
if value.Level == "" { if value.Level == "" {
return LogEvent{}, errors.New("log level is required") 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() { if value.Time.IsZero() {
value.Time = time.Now().UTC() 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) INSERT INTO log_events (event_time, level, message, caller, fields_json)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields)) `, 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 { if err != nil {
return LogEvent{}, fmt.Errorf("read log event id: %w", err) 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 return value, nil
} }
@@ -159,6 +186,10 @@ func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent
clauses = append(clauses, `level = ?`) clauses = append(clauses, `level = ?`)
args = append(args, strings.ToLower(filter.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() { if !filter.Since.IsZero() {
clauses = append(clauses, `event_time >= ?`) clauses = append(clauses, `event_time >= ?`)
args = append(args, filter.Since.UTC().Unix()) args = append(args, filter.Since.UTC().Unix())
@@ -236,6 +267,29 @@ func (s *Store) CountLogEvents(ctx context.Context) (int64, error) {
return count, nil 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 // PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
// rest. keep <= 0 deletes everything. // rest. keep <= 0 deletes everything.
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) { 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_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))`, `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: default:
return nil return nil
} }
+18 -15
View File
@@ -467,24 +467,27 @@ type LogEvent struct {
} }
type LogFilter struct { type LogFilter struct {
Level string Level string
Since time.Time ExcludeMessage string
Until time.Time Since time.Time
BeforeID int64 Until time.Time
Limit int BeforeID int64
Limit int
} }
type CardPolicy struct { type CardPolicy struct {
ICCID string ICCID string
NetworkEnabled bool NetworkEnabled bool
VoWiFiEnabled bool VoWiFiEnabled bool
AirplaneEnabled bool AirplaneEnabled bool
APN string APN string
IPVersion string IPVersion string
CustomPhoneNumber string CustomPhoneNumber string
Source string CellularIMSEnabled bool
CreatedAt time.Time CellularIMSManaged bool
UpdatedAt time.Time Source string
CreatedAt time.Time
UpdatedAt time.Time
} }
type CardAPNProfile struct { 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, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO card_policies ( INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled, 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,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET ON CONFLICT(iccid) DO UPDATE SET
network_enabled = excluded.network_enabled, network_enabled = excluded.network_enabled,
vowifi_enabled = excluded.vowifi_enabled, vowifi_enabled = excluded.vowifi_enabled,
@@ -395,12 +396,15 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
apn = excluded.apn, apn = excluded.apn,
ip_version = excluded.ip_version, ip_version = excluded.ip_version,
custom_phone_number = excluded.custom_phone_number, custom_phone_number = excluded.custom_phone_number,
cellular_ims_enabled = excluded.cellular_ims_enabled,
cellular_ims_managed = excluded.cellular_ims_managed,
source = excluded.source, source = excluded.source,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`, `,
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled), value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion, 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 { if err != nil {
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err) 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 = ` const cardPolicySelect = `
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled, 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` FROM card_policies`
func cardPolicy(row rowScanner) (CardPolicy, error) { func cardPolicy(row rowScanner) (CardPolicy, error) {
var value CardPolicy var value CardPolicy
var networkEnabled, vowifiEnabled, airplaneEnabled int var networkEnabled, vowifiEnabled, airplaneEnabled, cellularIMSEnabled, cellularIMSManaged int
var createdAt, updatedAt int64 var createdAt, updatedAt int64
err := row.Scan( err := row.Scan(
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled, &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) { if errors.Is(err, sql.ErrNoRows) {
return CardPolicy{}, ErrNotFound return CardPolicy{}, ErrNotFound
@@ -466,6 +472,8 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
value.NetworkEnabled = networkEnabled != 0 value.NetworkEnabled = networkEnabled != 0
value.VoWiFiEnabled = vowifiEnabled != 0 value.VoWiFiEnabled = vowifiEnabled != 0
value.AirplaneEnabled = airplaneEnabled != 0 value.AirplaneEnabled = airplaneEnabled != 0
value.CellularIMSEnabled = cellularIMSEnabled != 0
value.CellularIMSManaged = cellularIMSManaged != 0
value.CreatedAt = time.Unix(createdAt, 0).UTC() value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC() value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil return value, nil
+5 -2
View File
@@ -8,18 +8,21 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
const schemaVersion = 19 const schemaVersion = 20
var ErrNotFound = errors.New("store: not found") var ErrNotFound = errors.New("store: not found")
// Store owns the SQLite connection used by the process. // Store owns the SQLite connection used by the process.
type Store struct { type Store struct {
db *sql.DB db *sql.DB
logMu sync.Mutex
logClearedAt time.Time
} }
type Admin struct { type Admin struct {
+33 -19
View File
@@ -42,8 +42,10 @@ type CarrierProfile struct {
IMSRegisterProfile string IMSRegisterProfile string
IMSIPSecEncryption string IMSIPSecEncryption string
SMSCenter string SMSCenter string
PANIEnabled *bool
PANICountry string PANICountry string
PANINode string PANINode string
IMSUserAgent string
IMSDialURIScheme string IMSDialURIScheme string
IMSUserEqPhone bool IMSUserEqPhone bool
IMSVoiceCodecs []string IMSVoiceCodecs []string
@@ -57,7 +59,6 @@ type IMSRegisterOptions struct {
ContactExtraTags []string ContactExtraTags []string
SupportedHeader *string SupportedHeader *string
AllowHeader *string AllowHeader *string
UserAgent string
PPreferredIdentity bool PPreferredIdentity bool
PVisitedNetworkID string PVisitedNetworkID string
PAccessNetworkInfo *string PAccessNetworkInfo *string
@@ -68,6 +69,7 @@ type IMSRegisterOptions struct {
const ( const (
IMSContactFormatStandard = "standard" IMSContactFormatStandard = "standard"
IMSContactFormatATT = "att" IMSContactFormatATT = "att"
IMSContactFormatGSMA = "gsma"
) )
type carrierProfileDocument struct { type carrierProfileDocument struct {
@@ -76,13 +78,13 @@ type carrierProfileDocument struct {
} }
type carrierProfileRule struct { type carrierProfileRule struct {
ID string `json:"id"` ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"` Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"` MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"` Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"` EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"` IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"` IMS carrierProfileIMS `json:"ims,omitzero"`
} }
type carrierProfileMatch struct { type carrierProfileMatch struct {
@@ -116,8 +118,10 @@ type carrierProfileIMS struct {
RegisterProfile string `json:"register_profile,omitempty"` RegisterProfile string `json:"register_profile,omitempty"`
IPSecEncryption string `json:"ipsec_encryption,omitempty"` IPSecEncryption string `json:"ipsec_encryption,omitempty"`
SMSCenter string `json:"sms_center,omitempty"` SMSCenter string `json:"sms_center,omitempty"`
PANIEnabled *bool `json:"pani_enabled,omitempty"`
PANICountry string `json:"pani_country,omitempty"` PANICountry string `json:"pani_country,omitempty"`
PANINode string `json:"pani_node,omitempty"` PANINode string `json:"pani_node,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
DialURIScheme string `json:"dial_uri_scheme,omitempty"` DialURIScheme string `json:"dial_uri_scheme,omitempty"`
UserEqPhone *bool `json:"user_eq_phone,omitempty"` UserEqPhone *bool `json:"user_eq_phone,omitempty"`
VoiceCodecs []string `json:"voice_codecs,omitempty"` VoiceCodecs []string `json:"voice_codecs,omitempty"`
@@ -131,7 +135,6 @@ type carrierProfileRegisterOptions struct {
ContactExtraTags []string `json:"contact_extra_tags,omitempty"` ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
SupportedHeader *string `json:"supported_header,omitempty"` SupportedHeader *string `json:"supported_header,omitempty"`
AllowHeader *string `json:"allow_header,omitempty"` AllowHeader *string `json:"allow_header,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"` PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"` PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"` PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
@@ -322,6 +325,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false return false
} }
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" && 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') { (len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
return false return false
} }
@@ -340,7 +344,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false return false
} }
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" && if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
format != IMSContactFormatStandard && format != IMSContactFormatATT { format != IMSContactFormatStandard && format != IMSContactFormatATT && format != IMSContactFormatGSMA {
return false return false
} }
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags { for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
@@ -353,7 +357,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false 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") { if strings.ContainsAny(value, "\r\n") {
return false return false
} }
@@ -479,8 +483,12 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
}{ }{
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI}, {name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID}, {name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true}, // GID values identify an MVNO/service profile within a host network and
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true}, // 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 { if len(selector.values) == 0 {
continue continue
@@ -584,8 +592,15 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
base.IMSIPSecEncryption = value base.IMSIPSecEncryption = value
} }
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter) 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.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
base.PANINode = strings.TrimSpace(rule.IMS.PANINode) 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 != "" { if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
base.IMSDialURIScheme = value base.IMSDialURIScheme = value
} }
@@ -620,9 +635,6 @@ func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOp
value := strings.TrimSpace(*rule.AllowHeader) value := strings.TrimSpace(*rule.AllowHeader)
base.AllowHeader = &value base.AllowHeader = &value
} }
if value := strings.TrimSpace(rule.UserAgent); value != "" {
base.UserAgent = value
}
if rule.PPreferredIdentity { if rule.PPreferredIdentity {
base.PPreferredIdentity = true base.PPreferredIdentity = true
} }
@@ -719,8 +731,8 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.ICCID) != "" { if strings.TrimSpace(identity.ICCID) != "" {
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok { if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
imsiCountry := countryCodeForMCC(identity.HomeMCC) imsiCountry := CountryCodeForMCC(identity.HomeMCC)
iccidCountry := countryCodeForMCC(mcc) iccidCountry := CountryCodeForMCC(mcc)
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) { if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
identity.HomeMCC = mcc identity.HomeMCC = mcc
identity.HomeMNC = mnc identity.HomeMNC = mnc
@@ -733,7 +745,9 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
return identity 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) { switch strings.TrimSpace(mcc) {
case "515": case "515":
return "PH" 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) { func TestResolveCarrierProfileATT(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{ profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410", 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) { func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"}) profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
if profile.ID != CarrierProfileStandard { if profile.ID != CarrierProfileStandard {
@@ -69,6 +104,9 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
if profile.IMSRegisterOptions.SupportedHeader != nil { if profile.IMSRegisterOptions.SupportedHeader != nil {
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader) 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 { if profile.AllowSMSWithoutContactConfirmation {
t.Fatal("standard profile should require SMS contact confirmation") 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") 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 { 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) 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"): 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) 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"): case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath) 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" { if rule.IMS.IPSecEncryption != "aes-cbc" {
t.Fatalf("converted IMS profile = %#v", rule.IMS) 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{ for _, code := range []string{
"remote_certificate_bypass_ignored", "remote_certificate_bypass_ignored",
"disabled_dpd_ignored", "disabled_dpd_ignored",
+33 -2
View File
@@ -570,6 +570,14 @@
{ {
"id": "ipcc-redpocket-310170", "id": "ipcc-redpocket-310170",
"match_any": [ "match_any": [
{
"home_plmns": [
"310170"
],
"gid1_prefixes": [
"42"
]
},
{ {
"home_plmns": [ "home_plmns": [
"310410" "310410"
@@ -585,6 +593,18 @@
"gid1_prefixes": [ "gid1_prefixes": [
"42" "42"
] ]
},
{
"home_plmns": [
"310170",
"310410",
"310280"
],
"spns": [
"Red Pocket",
"RedPocket",
"Red Pocket Mobile"
]
} }
], ],
"epdg": { "epdg": {
@@ -5766,7 +5786,18 @@
"proposal": "modern" "proposal": "modern"
}, },
"ims": { "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 "version": 1
} }
+82 -1
View File
@@ -29,6 +29,7 @@ var (
const ( const (
usimAIDPrefix = "A0000000871002" usimAIDPrefix = "A0000000871002"
isimAIDPrefix = "A0000000871004" isimAIDPrefix = "A0000000871004"
efDIRFileID = 0x2f00
efADDecimal = 28589 // 0x6FAD efADDecimal = 28589 // 0x6FAD
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN) efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
channelCleanupTimeout = 3 * time.Second 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 // AT+CUAD is optional on older EC20 firmware. CCHO still provides a
// standards-based, evidence-bearing probe of the assigned USIM AID. // standards-based, evidence-bearing probe of the assigned USIM AID.
@@ -1053,6 +1067,64 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication(
return aidPrefix, application, nil 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( func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context, ctx context.Context,
deviceID string, deviceID string,
@@ -1174,8 +1246,17 @@ func (adapter *EC20Adapter) transmitBasicAPDU(
if err != nil { if err != nil {
return nil, err return nil, err
} }
collected = append(collected, body...)
sw1 := byte(status >> 8) 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 { if sw1 != 0x61 && sw1 != 0x9f {
collected = append(collected, byte(status>>8), byte(status)) collected = append(collected, byte(status>>8), byte(status))
return collected, nil 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( func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse(
t *testing.T, 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 var systemErr error
for _, targetHost := range hostsToTry { for _, targetHost := range hostsToTry {
addresses, err := resolver.LookupIPAddr(ctx, targetHost) ips, err := resolver.LookupIP(ctx, "ip4", targetHost)
if err == nil { if err == nil {
addresses := make([]net.IPAddr, 0, len(ips))
for _, ip := range ips {
addresses = append(addresses, net.IPAddr{IP: ip})
}
valid := filterValidPublicEPDGAddresses(addresses) valid := filterValidPublicEPDGAddresses(addresses)
if len(valid) > 0 { if len(valid) > 0 {
return valid, nil 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") return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated")
} }
payloadLength := int(binary.BigEndian.Uint16(packet[4:6])) 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") return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid")
} }
packet = packet[:declaredLength]
metadata := innerPacketMetadata{ metadata := innerPacketMetadata{
source: append(net.IP(nil), packet[8:24]...), source: append(net.IP(nil), packet[8:24]...),
destination: append(net.IP(nil), packet[24:40]...), 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 { func mustDefaultESPTunnel(t *testing.T) *espTunnel {
t.Helper() t.Helper()
return mustTestESPTunnel( 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-Identity: <"+preferredIdentity+">",
"P-Preferred-Service: "+mmtelServiceURN, "P-Preferred-Service: "+mmtelServiceURN,
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`, `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", "Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
"Supported: 100rel, timer, replaces", "Supported: 100rel, timer, replaces",
"Session-Expires: 1800;refresher=uac", "Session-Expires: 1800;refresher=uac",
@@ -146,6 +148,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
session.callMu.Unlock() session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil { if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS call started", session.provider.config.Logger.Info("IMS call started",
"category", "call",
"device_id", session.request.DeviceID,
"direction", "outgoing", "direction", "outgoing",
"identity_source", identitySource, "identity_source", identitySource,
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")), "target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
@@ -228,6 +232,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
} else { } else {
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil { 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", 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, "carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode, "sip_status", response.StatusCode,
"error", safeSIPDiagnostic(ackErr.Error()), "error", safeSIPDiagnostic(ackErr.Error()),
@@ -368,6 +374,14 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
session.callMu.Lock() session.callMu.Lock()
session.calls[callID] = call session.calls[callID] = call
session.callMu.Unlock() 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 { if session.provider != nil && session.provider.config.OnIncomingCall != nil {
calledNumber := identityNumber(request.value("To")) calledNumber := identityNumber(request.value("To"))
if calledNumber == "" { if calledNumber == "" {
@@ -487,8 +501,10 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon
"To: "+to, "To: "+to,
"Call-ID: "+call.callID, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d ACK", call.cseq), 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", "", "", "Content-Length: 0", "", "",
) )
session.writeMu.Lock() session.writeMu.Lock()
@@ -565,8 +581,10 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
lines = append(lines, lines = append(lines,
"From: "+from, "To: "+to, "Call-ID: "+call.callID, "From: "+from, "To: "+to, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq, 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", "", "", "Content-Length: 0", "", "",
) )
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second) 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, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d %s", cseq, method), fmt.Sprintf("CSeq: %d %s", cseq, method),
"Supported: 100rel, timer", "Supported: 100rel, timer",
"User-Agent: "+session.callUserAgent(), "User-Agent: "+session.imsUserAgent(),
) )
if method != "CANCEL" { if method != "CANCEL" {
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo()) lines = session.appendPAccessNetworkInfoHeader(lines)
} }
if method == "UPDATE" { if method == "UPDATE" {
lines = append(lines, session.dialogContactHeader()) 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) == "" { if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
return "" 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 + ">" contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
if strings.TrimSpace(session.instanceID) != "" { if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"` contact += `;+sip.instance="<` + session.instanceID + `>"`
@@ -799,19 +824,20 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
} }
func (session *Session) pAccessNetworkInfo() string { func (session *Session) pAccessNetworkInfo() string {
if session.paniResolved { if session == nil {
return ueProvidedPANI(session.pani) return ""
} }
return sessionPAccessNetworkInfo(session.instanceID) if session.paniResolved {
return session.pani
}
return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger())
} }
func (session *Session) callUserAgent() string { func (session *Session) appendPAccessNetworkInfoHeader(lines []string) []string {
if session != nil && session.provider != nil { if pani := session.pAccessNetworkInfo(); pani != "" {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" { return append(lines, "P-Access-Network-Info: "+pani)
return value
}
} }
return "vocat/1" return lines
} }
func callResponseDiagnostic(response *sipResponse) string { func callResponseDiagnostic(response *sipResponse) string {
@@ -837,6 +863,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
return return
} }
session.provider.config.Logger.Info("IMS call response", session.provider.config.Logger.Info("IMS call response",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID, "carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode, "sip_status", response.StatusCode,
"diagnostic", diagnostic, "diagnostic", diagnostic,
+53 -1
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"io" "io"
"net" "net"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -229,7 +231,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
"P-Preferred-Identity: <tel:+447700900123>\r\n", "P-Preferred-Identity: <tel:+447700900123>\r\n",
"P-Preferred-Service: " + mmtelServiceURN + "\r\n", "P-Preferred-Service: " + mmtelServiceURN + "\r\n",
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\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", "User-Agent: VoCat Test\r\n",
"Accept: application/sdp\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) { func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
session := &Session{ session := &Session{
identity: identitySet{ identity: identitySet{
+128 -26
View File
@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -24,6 +23,7 @@ const (
defaultRegistrationExpiry = 3600 * time.Second defaultRegistrationExpiry = 3600 * time.Second
defaultTransactionTimeout = 12 * time.Second defaultTransactionTimeout = 12 * time.Second
maxAuthenticationChallenges = 3 maxAuthenticationChallenges = 3
defaultPANIWLANNode = "ffffffffffff"
) )
var ( var (
@@ -649,6 +649,11 @@ func newSession(
if err != nil { if err != nil {
return nil, err 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()) refreshContext, refreshCancel := context.WithCancel(context.Background())
session := &Session{ session := &Session{
provider: provider, provider: provider,
@@ -660,8 +665,8 @@ func newSession(
conn: connection, conn: connection,
callID: callToken + "@" + addressHost(connection.LocalAddr()), callID: callToken + "@" + addressHost(connection.LocalAddr()),
fromTag: fromTag, fromTag: fromTag,
instanceID: "urn:uuid:" + instanceID, instanceID: instanceURI,
pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID), pani: resolveSessionPAccessNetworkInfo(request.Identity, provider.config.Logger),
paniResolved: true, paniResolved: true,
cseq: 1, cseq: 1,
refreshContext: refreshContext, refreshContext: refreshContext,
@@ -970,11 +975,7 @@ func (session *Session) buildRegister(
allow = *registerOptions.AllowHeader allow = *registerOptions.AllowHeader
} }
userAgent := strings.TrimSpace(session.provider.config.UserAgent) userAgent := session.imsUserAgent()
if override := strings.TrimSpace(registerOptions.UserAgent); override != "" &&
(userAgent == "" || userAgent == "vocat/1") {
userAgent = override
}
lines := []string{ lines := []string{
"REGISTER " + requestURI + " SIP/2.0", "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>"`, `%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
base, extra, icsiRef, instanceID, 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: default:
extra := "" extra := ""
for _, tag := range registerOptions.ContactExtraTags { for _, tag := range registerOptions.ContactExtraTags {
@@ -1076,41 +1086,127 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
} }
} }
// sessionPAccessNetworkInfo creates a syntactically valid, locally // sipInstanceID uses the standardized GSMA device-instance URI when a valid
// administered unicast WLAN node identifier from the already-random SIP // modem identity is available and keeps the generated UUID as the fallback.
// instance ID. It discloses neither a real BSSID nor subscriber identity, but func sipInstanceID(identity vowifi.SIMIdentity, fallback string) string {
// remains stable for every transaction belonging to this IMS registration. imei := strings.TrimSpace(identity.IMEI)
func sessionPAccessNetworkInfo(instanceID string) string { if len(imei) == 15 {
instanceID = strings.TrimSpace(instanceID) valid := true
if instanceID == "" { for _, digit := range imei {
return "" if digit < '0' || digit > '9' {
valid = false
break
}
}
if valid {
return "urn:gsma:imei:" + imei + "-0"
}
} }
digest := sha256.Sum256([]byte(instanceID)) return "urn:uuid:" + strings.TrimSpace(fallback)
digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast }
return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6])
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 // resolveSessionPAccessNetworkInfo freezes the selected value when the IMS
// session is created. This prevents a carrier-profile reload from changing // session is created. This prevents a carrier-profile reload from changing
// access identity between REGISTER, SMS MESSAGE and its RP-ACK. // 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) profile := vowifi.ResolveCarrierProfile(identity)
if profile.PANIEnabled != nil && !*profile.PANIEnabled {
return ""
}
if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil { if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil {
return ueProvidedPANI(*configured) return appendPaniCountry(ueProvidedPANI(*configured), identity, profile, logger)
} }
node := strings.ToLower(strings.TrimSpace(profile.PANINode)) node := strings.ToLower(strings.TrimSpace(profile.PANINode))
if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 { 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 == "" { if node == "" {
return "" return ""
} }
value := "IEEE-802.11;i-wlan-node-id=" + node value := "IEEE-802.11;i-wlan-node-id=" + node
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" { return appendPaniCountry(value, identity, profile, logger)
value += ";country=" + country }
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. // 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")) serviceRoutes := splitHeaderValues(response.values("Service-Route"))
registeredContact := "" registeredContact := ""
smsConfirmed := false smsConfirmed := false
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
instanceLower := strings.ToLower(session.instanceID) instanceLower := strings.ToLower(session.instanceID)
contactURILower := strings.ToLower(fmt.Sprintf( contactURILower := strings.ToLower(fmt.Sprintf(
"sip:%s@%s;transport=%s", "sip:%s@%s;transport=%s",
@@ -1270,10 +1367,15 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
session.contactAddress(), session.contactAddress(),
session.transport, session.transport,
)) ))
contactAddressLower := ""
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
contactAddressLower = strings.ToLower("sip:" + session.contactAddress())
}
for _, contact := range contacts { for _, contact := range contacts {
lower := strings.ToLower(contact) lower := strings.ToLower(contact)
matchesThisSession := strings.Contains(lower, instanceLower) || matchesThisSession := strings.Contains(lower, instanceLower) ||
strings.Contains(lower, contactURILower) strings.Contains(lower, contactURILower) ||
(contactAddressLower != "" && strings.Contains(lower, contactAddressLower))
if matchesThisSession { if matchesThisSession {
registeredContact = contact registeredContact = contact
smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip") 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) { func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001" defaultPANI := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
first := sessionPAccessNetworkInfo(instanceID) if err := validateTestPANI(defaultPANI); err != nil {
second := sessionPAccessNetworkInfo(instanceID)
if first != second {
t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second)
}
if err := validateTestPANI(first); err != nil {
t.Fatal(err) 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" { 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 != "" { if got := ueProvidedPANI("network-provided"); got != "" {
t.Fatalf("marker-only PANI = %q, want empty", got) t.Fatalf("marker-only PANI = %q, want empty", got)
} }
if got := (&Session{paniResolved: true}).pAccessNetworkInfo(); 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("explicitly omitted session PANI = %q, want empty", got) 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 { func validateTestPANI(value string) error {
const prefix = "IEEE-802.11;i-wlan-node-id=" const accessType = "IEEE-802.11"
if !strings.HasPrefix(value, prefix) { if !strings.HasPrefix(value, accessType+";") {
return fmt.Errorf("value %q does not start with %q", value, prefix) return fmt.Errorf("value %q does not start with %q", value, accessType+";")
} }
if strings.Contains(strings.ToLower(value), "network-provided") { if strings.Contains(strings.ToLower(value), "network-provided") {
return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value) 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 { if err != nil || len(node) != 6 {
return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value) 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 { if node[0]&0x03 != 0x02 {
return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value) 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 { if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger 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 { if request != nil {
base = append(base, base = append(base,
"call_id", strings.TrimSpace(request.value("Call-ID")), "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) plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
base := []any{ base := []any{
"category", "sms",
"subsystem", "ims",
"device_id", session.request.DeviceID, "device_id", session.request.DeviceID,
"home_plmn", plmn, "home_plmn", plmn,
"transport", session.transport, "transport", session.transport,
@@ -1175,6 +1177,7 @@ func (session *Session) sendSIPMessageWith(
lines = append(lines, lines = append(lines,
"Request-Disposition: no-fork", "Request-Disposition: no-fork",
"Allow: MESSAGE", "Allow: MESSAGE",
"User-Agent: "+session.imsUserAgent(),
) )
if inReplyTo != "" { if inReplyTo != "" {
lines = append(lines, "In-Reply-To: "+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)) raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge))
if err != nil { if err != nil {
return AKAResult{}, ErrEC20AKACommand return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20AKACommand, err)
} }
return parseUSIMAuthenticateResponse(raw) return parseUSIMAuthenticateResponse(raw)
} }
@@ -3,6 +3,7 @@ import { CardUiRegular } from "@fluentui/react-icons";
import { Button, Input, Tag, message } from "../ui"; import { Button, Input, Tag, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard"; import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN"; import { CardPolicyAPN } from "./CardPolicyAPN";
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
import { useCardPolicyToggles } from "./useCardPolicyToggles"; import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions"; import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
import type { CardPolicy } from "../../types"; import type { CardPolicy } from "../../types";
@@ -141,6 +142,17 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
failed={toggles.airplaneFailed} failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle} onToggle={toggles.onAirplaneToggle}
/> : null} /> : 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> </div>
{!wifiCallingOnly ? <CardPolicyAPN {!wifiCallingOnly ? <CardPolicyAPN
deviceId={deviceId} 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 { Button, Spinner } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard"; import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN"; import { CardPolicyAPN } from "./CardPolicyAPN";
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
import { useCardPolicyToggles } from "./useCardPolicyToggles"; import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions"; import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import type { CardPolicy } from "../../types"; 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} {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 <PolicySwitchCard
compact compact
title="VoWiFi" title="VoWiFi"
@@ -87,6 +88,18 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
failed={toggles.airplaneFailed} failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle} 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> </div>
<CardPolicyAPN <CardPolicyAPN
deviceId={deviceId} deviceId={deviceId}
@@ -28,6 +28,25 @@ export interface CardPolicyUpdate {
apn?: string; apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6"; ipVersion?: "IP" | "IPV6" | "IPV4V6";
customPhoneNumber?: string; 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) { export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body }); 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); const localRef = useRef(local);
localRef.current = 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(() => { useEffect(() => {
if (!source) return; if (sourceVoWiFiEnabled === undefined || sourceAirplaneEnabled === undefined) return;
setLocal({ vowifiEnabled: source.vowifiEnabled, airplaneEnabled: source.airplaneEnabled }); setLocal((current) => {
if (
current.vowifiEnabled === sourceVoWiFiEnabled &&
current.airplaneEnabled === sourceAirplaneEnabled
) return current;
return {
vowifiEnabled: sourceVoWiFiEnabled,
airplaneEnabled: sourceAirplaneEnabled,
};
});
setVowifiFailed(false); setVowifiFailed(false);
setAirplaneFailed(false); setAirplaneFailed(false);
}, [source]); }, [sourceVoWiFiEnabled, sourceAirplaneEnabled]);
async function toggle( async function toggle(
field: Field, field: Field,
+10 -6
View File
@@ -11,12 +11,13 @@ import { message } from "../ui/message";
type RetentionMode = LoggingSettings["mode"]; type RetentionMode = LoggingSettings["mode"];
// 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。 // 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。
export function LogRetentionCard() { export function LogRetentionCard({ refreshKey = 0 }: { refreshKey?: number }) {
const { t } = useI18n(); const { t } = useI18n();
const [mode, setMode] = useState<RetentionMode>("unlimited"); const [mode, setMode] = useState<RetentionMode>("unlimited");
const [count, setCount] = useState(10000); const [count, setCount] = useState(10000);
const [days, setDays] = useState(30); const [days, setDays] = useState(30);
const [storedLogs, setStoredLogs] = useState(0); const [storedLogs, setStoredLogs] = useState(0);
const [maxLogs, setMaxLogs] = useState(10000);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -25,6 +26,7 @@ export function LogRetentionCard() {
setCount(data.count); setCount(data.count);
setDays(data.days); setDays(data.days);
setStoredLogs(data.storedLogs); setStoredLogs(data.storedLogs);
setMaxLogs(data.maxLogs || 10000);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -41,14 +43,14 @@ export function LogRetentionCard() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [apply]); }, [apply, refreshKey]);
const save = useCallback(async () => { const save = useCallback(async () => {
setSaving(true); setSaving(true);
try { try {
const data = await updateLoggingSettings({ const data = await updateLoggingSettings({
mode, 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), days: Math.max(1, Math.trunc(days) || 1),
}); });
apply(data); apply(data);
@@ -58,7 +60,7 @@ export function LogRetentionCard() {
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [mode, count, days, apply]); }, [mode, count, days, maxLogs, apply]);
const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => { const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseInt(event.target.value, 10); const parsed = parseInt(event.target.value, 10);
@@ -78,7 +80,7 @@ export function LogRetentionCard() {
className="w-32" className="w-32"
disabled={loading} disabled={loading}
options={[ options={[
{ value: "unlimited", label: t("不限制") }, { value: "unlimited", label: t("最多 10000 条") },
{ value: "count", label: t("按条数") }, { value: "count", label: t("按条数") },
{ value: "days", label: t("按天数") }, { value: "days", label: t("按天数") },
]} ]}
@@ -88,6 +90,7 @@ export function LogRetentionCard() {
<Input <Input
type="number" type="number"
min={1} min={1}
max={maxLogs}
value={count === 0 ? "" : count} value={count === 0 ? "" : count}
onChange={onNumber(setCount)} onChange={onNumber(setCount)}
disabled={loading} disabled={loading}
@@ -110,8 +113,9 @@ export function LogRetentionCard() {
</label> </label>
) : null} ) : null}
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">
{t("当前已存储")} {storedLogs} {t("条")} {t("当前已存储")} {storedLogs} / {maxLogs} {t("条")}
</span> </span>
<span className="text-xs text-gray-400">{t("达到上限后自动删除最旧日志")}</span>
<div className="flex-1" /> <div className="flex-1" />
<Button <Button
size="small" size="small"
+16
View File
@@ -498,6 +498,22 @@ export const EN_DICT: Record<string, string> = {
"连接中断,正在尝试重连…": "Connection lost, reconnecting…", "连接中断,正在尝试重连…": "Connection lost, reconnecting…",
: "Logs exported", : "Logs exported",
"查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search", "查看系统运行日志,支持过滤和搜索": "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", : "Resume",
: "Pause", : "Pause",
: "Connected", : "Connected",
+113 -26
View File
@@ -16,11 +16,13 @@ import { Switch } from "../components/ui/Switch";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
import { Input } from "../components/ui/Input"; import { Input } from "../components/ui/Input";
import { message } from "../components/ui/message"; import { message } from "../components/ui/message";
import { confirmDialog } from "../components/ui/MessageBox";
import { LogRetentionCard } from "../components/logs/LogRetentionCard"; import { LogRetentionCard } from "../components/logs/LogRetentionCard";
const MAX_LOGS = 1000; const MAX_LOGS = 1000;
type Level = "all" | "debug" | "info" | "warn" | "error"; type Level = "all" | "debug" | "info" | "warn" | "error";
type Category = "all" | "hardware" | "network" | "vowifi" | "sms" | "call" | "operation" | "system";
const LEVEL_OPTIONS: { value: Level; label: string }[] = [ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "all", label: "全部" }, { value: "all", label: "全部" },
@@ -30,6 +32,17 @@ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "error", label: "ERROR" }, { 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 { function levelColor(level: string): string {
switch (level.toLowerCase()) { switch (level.toLowerCase()) {
case "debug": case "debug":
@@ -52,6 +65,42 @@ function fieldsText(fields: LogEntry["fields"]): string {
return typeof fields === "string" ? fields : JSON.stringify(fields); 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. // Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp.
function displayTime(time: string): string { function displayTime(time: string): string {
try { try {
@@ -71,8 +120,11 @@ export default function LogsPage() {
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [autoTail, setAutoTail] = useState(true); const [autoTail, setAutoTail] = useState(true);
const [level, setLevel] = useState<Level>("all"); const [level, setLevel] = useState<Level>("all");
const [category, setCategory] = useState<Category>("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [connError, setConnError] = useState(""); const [connError, setConnError] = useState("");
const [clearing, setClearing] = useState(false);
const [retentionRefreshKey, setRetentionRefreshKey] = useState(0);
const esRef = useRef<EventSource | null>(null); const esRef = useRef<EventSource | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null); const logContainerRef = useRef<HTMLDivElement>(null);
@@ -80,6 +132,7 @@ export default function LogsPage() {
const levelRef = useRef<Level>("all"); const levelRef = useRef<Level>("all");
const appendLog = useCallback((entry: LogEntry) => { const appendLog = useCallback((entry: LogEntry) => {
if (isHTTPAccessLog(entry)) return;
setLogs((prev) => { setLogs((prev) => {
const next = [...prev, entry]; const next = [...prev, entry];
return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next; return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
@@ -118,7 +171,7 @@ export default function LogsPage() {
try { try {
const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500"); const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500");
const list = Array.isArray(res) ? res : (res?.logs ?? []); const list = Array.isArray(res) ? res : (res?.logs ?? []);
setLogs(list.slice(-MAX_LOGS)); setLogs(list.filter((entry) => !isHTTPAccessLog(entry)).slice(-MAX_LOGS));
} catch { } catch {
/* 历史回填失败不阻塞实时流 */ /* 历史回填失败不阻塞实时流 */
} finally { } finally {
@@ -164,13 +217,34 @@ export default function LogsPage() {
} }
}, [connect]); }, [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(() => { const filtered = useMemo(() => {
let list = logs; let list = logs;
if (level !== "all") { if (level !== "all") {
list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase()); list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase());
} }
if (category !== "all") {
list = list.filter((entry) => logCategory(entry) === category);
}
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
list = list.filter( list = list.filter(
@@ -181,14 +255,14 @@ export default function LogsPage() {
); );
} }
return list; return list;
}, [logs, level, search]); }, [logs, level, category, search]);
const exportLogs = useCallback(() => { const exportLogs = useCallback(() => {
const text = filtered const text = filtered
.map((v) => { .map((v) => {
const time = new Date(v.time).toLocaleString(); const time = new Date(v.time).toLocaleString();
const fields = v.fields ? ` ${fieldsText(v.fields)}` : ""; 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"); .join("\n");
const blob = new Blob([text], { type: "text/plain" }); const blob = new Blob([text], { type: "text/plain" });
@@ -204,8 +278,8 @@ export default function LogsPage() {
return ( return (
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<PageHeader <PageHeader
title={t("实时日志")} title={t("设备与业务日志")}
subtitle={t("查看系统运行日志,支持过滤和搜索")} subtitle={t("记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码")}
actions={ actions={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
@@ -216,7 +290,7 @@ export default function LogsPage() {
> >
{paused ? t("继续") : t("暂停")} {paused ? t("继续") : t("暂停")}
</Button> </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("清空")} {t("清空")}
</Button> </Button>
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}> <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" className="w-full sm:w-40"
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} 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 <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
@@ -279,7 +360,7 @@ export default function LogsPage() {
</div> </div>
</div> </div>
<LogRetentionCard /> <LogRetentionCard refreshKey={retentionRefreshKey} />
<div className="ui-card overflow-hidden"> <div className="ui-card overflow-hidden">
<div <div
@@ -291,24 +372,30 @@ export default function LogsPage() {
{loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")} {loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")}
</div> </div>
) : null} ) : null}
{filtered.map((entry, i) => ( {filtered.map((entry, i) => {
<div key={i} className="py-0.5 hover:bg-white/5 px-2 -mx-2 rounded whitespace-nowrap"> const entryCategory = logCategory(entry);
<span className="text-gray-500">[{displayTime(entry.time)}]</span> const fields = logFields(entry);
<span className={cx("font-bold ml-1.5", levelColor(entry.level))}> const hasRawError = fields.raw_error !== undefined || fields.error !== undefined || fields.raw_response !== undefined;
{entry.level.toUpperCase()} return (
</span> <div key={`${entry.time}-${i}`} className="border-b border-white/5 px-2 py-2 -mx-2 last:border-0 hover:bg-white/5">
<span <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
className="text-indigo-400 inline-block max-w-48 truncate align-bottom ml-1.5" <span className="text-gray-500">[{displayTime(entry.time)}]</span>
title={entry.caller ?? ""} <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))}>
{entry.caller ?? ""} {t(CATEGORY_OPTIONS.find((item) => item.value === entryCategory)?.label ?? "系统错误")}
</span> </span>
<span className="text-gray-100 ml-1.5">{entry.message}</span> {entry.caller ? <span className="max-w-48 truncate text-indigo-400" title={entry.caller}>{entry.caller}</span> : null}
{entry.fields ? ( <span className="break-words text-gray-100">{entry.message}</span>
<span className="text-amber-300/70 ml-1.5">{fieldsText(entry.fields)}</span> </div>
) : null} {entry.fields ? (
</div> <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> </div>
</div> </div>
+4 -1
View File
@@ -273,6 +273,8 @@ export interface CardPolicy {
apn?: string; apn?: string;
ipVersion?: string; ipVersion?: string;
customPhoneNumber?: string; customPhoneNumber?: string;
cellularImsEnabled: boolean;
cellularImsManaged: boolean;
source?: string; source?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@@ -432,12 +434,13 @@ export interface SecuritySettings {
clientAllowed: boolean; clientAllowed: boolean;
} }
// 运行日志保留策略:默认不限制,可按条数或天数限制。 // 运行日志保留策略:全局硬上限 10000 条,可配置更严格的条数或天数限制。
export interface LoggingSettings { export interface LoggingSettings {
mode: "unlimited" | "count" | "days"; mode: "unlimited" | "count" | "days";
count: number; count: number;
days: number; days: number;
storedLogs: number; storedLogs: number;
maxLogs: number;
} }
export interface SystemInfo { export interface SystemInfo {