mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 07:43:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a39ed3d31 | ||
|
|
b2daa972d2 | ||
|
|
54288e5657 | ||
|
|
76af0784e1 | ||
|
|
06ea65558c | ||
|
|
d26937f9eb | ||
|
|
688e1e8311 |
+147
-29
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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{}) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) + "]"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]...),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user