Compare commits

...
7 Commits
Author SHA1 Message Date
ihipopandGitHub 54288e5657 fix(ims): align VoWiFi SIP profile behavior (#75) 2026-08-21 22:48:55 +08:00
76af0784e1 fix: stabilize OpenStick 410 VoWiFi startup (#74)
* fix: stabilize OpenStick 410 VoWiFi startup

* fix: recover OpenStick 410 eUICC channel allocation

---------

Co-authored-by: MengMengCode <[email protected]>
2026-08-21 19:26:37 +08:00
06ea65558c fix: ignore non-voice CLCC records in call monitor (#71)
Co-authored-by: geekouc <[email protected]>
2026-08-21 15:44:00 +08:00
MengMengCode d26937f9eb Fix something 2026-08-21 12:25:16 +08:00
MengMengCode 688e1e8311 feat(logging): implement log retention policy with hard limit and exclusion filters
- Added MaxLogEvents constant to enforce a hard limit on stored log events.
- Updated AppendLogEvent to discard older logs when the limit is exceeded.
- Enhanced ListLogEvents to support filtering by log level and excluding specific messages.
- Introduced ClearLogEvents method to permanently remove logs and prevent re-queuing of cleared entries.
- Modified LogRetentionCard component to reflect the new log retention settings and limits.
- Added logging categories for better organization and filtering in the UI.
- Implemented sanitization for sensitive information in logs.
- Added tests for log event limits and clearing functionality.
2026-08-21 01:01:41 +08:00
MengMengCode f697c418a5 FIX #68 #28 2026-08-20 23:37:40 +08:00
MengMengCode 8d06231494 #28 2026-08-20 21:12:34 +08:00
50 changed files with 2209 additions and 184 deletions
+76 -8
View File
@@ -41,7 +41,7 @@ import (
)
func main() {
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
logs := loghub.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 2000)
logger := slog.New(logs)
args := os.Args[1:]
@@ -206,7 +206,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
}
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
deviceLogger := logger.With("category", "hardware")
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: deviceLogger})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -227,7 +228,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
}()
pollContext, cancelPolling := context.WithCancel(context.Background())
defer cancelPolling()
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
go pollDeviceSnapshots(pollContext, deviceLogger, database, deviceManager)
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
go collectCellularTraffic(pollContext, logger, database)
go persistLogsToStore(pollContext, logger, logs, database)
@@ -640,7 +641,7 @@ func configureVoWiFiRuntime(
Devices: mapper,
}
manager := vowifiruntime.New(vowifiruntime.Options{
Logger: logger,
Logger: logger.With("category", "vowifi"),
OnState: projector.Save,
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
deviceConfig, err := database.Device(factoryContext, deviceID)
@@ -683,7 +684,18 @@ func configureVoWiFiRuntime(
)
}
}
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
requestEnable := func() error {
_, requestErr := manager.RequestEnabled(deviceConfig.ID, true)
return requestErr
}
if err := requestVoWiFiStartup(
ctx,
logger,
deviceConfig.DeviceType,
deviceConfig.ID,
wifi410VoWiFiStartupDelay,
requestEnable,
); err != nil {
_ = manager.Close(context.Background())
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
}
@@ -695,8 +707,55 @@ func configureVoWiFiRuntime(
const (
vowifiStartupRadioAttempts = 3
vowifiStartupRadioDelay = time.Second
wifi410VoWiFiStartupDelay = 80 * time.Second
)
// requestVoWiFiStartup delays only the persisted startup policy for OpenStick
// 410 devices. Their Qualcomm UIM and Vodafone ePDG path need a short quiet
// period after a cold boot; user-triggered reconnects and every other device
// type continue to execute immediately.
func requestVoWiFiStartup(
ctx context.Context,
logger *slog.Logger,
deviceType string,
deviceID string,
delay time.Duration,
request func() error,
) error {
if deviceType != store.DeviceTypeWiFi410 || delay <= 0 {
return request()
}
if logger == nil {
logger = slog.Default()
}
logger.Info(
"OpenStick 410 VoWiFi startup delayed",
"device_id", deviceID,
"delay", delay,
)
go func() {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
if err := request(); err != nil {
logger.Warn(
"OpenStick 410 delayed VoWiFi startup failed",
"device_id", deviceID,
"error", err,
)
}
}()
return nil
}
func shouldDelayWiFi410VoWiFi(deviceType string, now, notBefore time.Time) bool {
return deviceType == store.DeviceTypeWiFi410 && now.Before(notBefore)
}
type flightModeSetter interface {
SetFlight(context.Context, string, bool) (device.FlightResult, error)
}
@@ -717,7 +776,7 @@ func protectVoWiFiStartupRadioWithRetry(
physicalID string,
attempts int,
delay time.Duration,
) error {
) error {
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
@@ -759,14 +818,15 @@ func newVoWiFiOrchestrator(
if apn == "" {
apn = "ims"
}
vowifiLogger := logger.With("category", "vowifi", "device_id", deviceConfig.ID)
tunnelProvider, err := ike.NewProvider(ike.Config{
APN: apn, Logger: logger, AutoProposalFallback: true,
APN: apn, Logger: vowifiLogger, AutoProposalFallback: true,
})
if err != nil {
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
}
imsProvider, err := ims.NewProvider(adapter, ims.Config{
Logger: logger,
Logger: vowifiLogger,
// Carrier-specific transport and SMSC defaults live in the shared data
// profile. Prefer network-provided P-CSCF hints, then safely try the
// alternate transport only if no SIP response was observed.
@@ -983,6 +1043,10 @@ func persistLogsToStore(
if !ok {
return
}
if loghub.IsHTTPAccessEntry(entry) {
continue
}
entry = loghub.SanitizeEntry(entry)
var fields json.RawMessage
if len(entry.Fields) > 0 {
if raw, err := json.Marshal(entry.Fields); err == nil {
@@ -1130,6 +1194,7 @@ func reconcileCardPolicies(
vowifiManager *vowifiruntime.Manager,
) {
observedCards := make(map[string]string)
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil {
@@ -1211,6 +1276,9 @@ func reconcileCardPolicies(
}
switch {
case stateErr != nil || !state.Enabled:
if shouldDelayWiFi410VoWiFi(config.DeviceType, time.Now(), wifi410StartupNotBefore) {
continue
}
_, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID)
+2 -1
View File
@@ -5,10 +5,12 @@ go 1.25.0
require (
github.com/coder/websocket v1.8.15
github.com/iniwex5/quectel-qmi-go v0.6.0
github.com/warthog618/sms v0.3.0
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.43.0
golang.org/x/text v0.41.0
howett.net/plist v1.0.1
modernc.org/sqlite v1.38.2
)
@@ -21,7 +23,6 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/warthog618/sms v0.3.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
+2
View File
@@ -46,6 +46,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+3
View File
@@ -439,6 +439,9 @@ func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, cand
}
const slot uint8 = 1
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid)
if recoverySession, recoveryOK := session.(nativeQMIChannelRecoverySession); recoveryOK && isQMIInsufficientResources(err) {
logicalChannel, err = openNativeQMIChannelWithRecovery(openContext, recoverySession, slot, aid)
}
if err != nil {
_ = session.Close()
return nil, fmt.Errorf("%w: %v", errNoEUICC, err)
+54
View File
@@ -206,6 +206,11 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
}
seen := make(map[string]struct{}, len(candidates))
type discoveryEvent struct {
connected bool
candidate modem.Candidate
}
events := make([]discoveryEvent, 0)
manager.mu.Lock()
for _, candidate := range candidates {
if strings.TrimSpace(candidate.ID) == "" {
@@ -218,8 +223,12 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
candidate: candidate,
discovered: true,
}
events = append(events, discoveryEvent{connected: true, candidate: candidate})
continue
}
if !state.discovered {
events = append(events, discoveryEvent{connected: true, candidate: candidate})
}
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
state.resetClientOnLock = true
}
@@ -231,10 +240,28 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if _, ok := seen[id]; ok {
continue
}
if state.discovered {
events = append(events, discoveryEvent{candidate: state.candidate})
}
state.discovered = false
stale = append(stale, state)
}
manager.mu.Unlock()
if manager.logger != nil {
for _, event := range events {
message := "hardware disconnected"
if event.connected {
message = "hardware connected"
}
manager.logger.Info(message,
"event", "hardware.discovery",
"device_id", event.candidate.ID,
"hardware_kind", event.candidate.HardwareKind,
"vendor_id", event.candidate.VendorID,
"product_id", event.candidate.ProductID,
)
}
}
for _, state := range stale {
state.opMu.Lock()
@@ -382,6 +409,11 @@ func (manager *Manager) setResult(
return
}
previousError := state.lastError
var previousSnapshot *Snapshot
if state.snapshot != nil {
value := *state.snapshot
previousSnapshot = &value
}
if snapshot != nil {
value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...)
@@ -394,6 +426,13 @@ func (manager *Manager) setResult(
state.lastError = ""
}
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
registrationChanged := snapshot != nil && manager.logger != nil &&
(previousSnapshot == nil ||
previousSnapshot.RegistrationStatus != snapshot.RegistrationStatus ||
previousSnapshot.OperatorCode != snapshot.OperatorCode ||
previousSnapshot.AccessTech != snapshot.AccessTech ||
previousSnapshot.PSAttached != snapshot.PSAttached ||
previousSnapshot.SIMStatus != snapshot.SIMStatus)
backend := state.backend
hardwareKind := state.candidate.HardwareKind
manager.mu.Unlock()
@@ -406,6 +445,21 @@ func (manager *Manager) setResult(
"error", HardwareErrorDetail(err),
)
}
if registrationChanged {
manager.logger.Info(
"cellular registration state changed",
"category", "network",
"event", "network.registration",
"device_id", id,
"sim_status", snapshot.SIMStatus,
"registration_status", snapshot.RegistrationStatus,
"registration_source", snapshot.RegistrationSource,
"operator", snapshot.OperatorName,
"operator_code", snapshot.OperatorCode,
"access_technology", snapshot.AccessTech,
"packet_service_attached", snapshot.PSAttached,
)
}
}
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
+247 -25
View File
@@ -8,7 +8,13 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf16"
"unicode/utf8"
"github.com/warthog618/sms/encoding/gsm7"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
var gsm7DefaultAlphabet = [128]rune{
@@ -770,14 +776,19 @@ func readTPAddress(cursor *pduCursor) (string, error) {
var septetCount int
if toa&0x70 == 0x50 {
// 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field
// indicates the number of useful semi-octets (i.e. characters * 7 / 4, rounded up).
// The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2.
// However, some non-standard sources specify length as the direct count of septets
// (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes).
if length >= 7 {
byteCount = (int(length) + 1) / 2
septetCount = int(length) * 4 / 7
} else {
// is a count of useful semi-octets, not a character count. In particular,
// a three-character sender such as "OKX" has length 6. Treating every
// short length as a septet count consumes PID/DCS bytes as part of the
// address and shifts the entire TPDU, producing plausible-looking GSM-7
// garbage instead of the message body.
byteCount = (int(length) + 1) / 2
septetCount = int(length) * 4 / 7
// A few legacy/non-standard sources do put the character count in this
// field. Retain compatibility only when the standard-sized value cannot
// be a valid zero-padded GSM-7 address; do not guess based on its length.
if byteCount == 0 || cursor.index+byteCount > len(cursor.data) ||
!hasZeroGSM7Padding(cursor.data[cursor.index:cursor.index+byteCount], septetCount) {
byteCount = (int(length)*7 + 7) / 8
septetCount = int(length)
}
@@ -798,6 +809,18 @@ func readTPAddress(cursor *pduCursor) (string, error) {
return decodeNumericAddress(value, int(length), toa), nil
}
func hasZeroGSM7Padding(data []byte, septetCount int) bool {
if septetCount <= 0 || septetCount*7 > len(data)*8 {
return false
}
for bit := septetCount * 7; bit < len(data)*8; bit++ {
if data[bit/8]&(byte(1)<<uint(bit%8)) != 0 {
return false
}
}
return true
}
func decodeNumericAddress(value []byte, digits int, toa byte) string {
var result strings.Builder
if toa&0x70 == 0x10 {
@@ -825,9 +848,9 @@ func decodeUserData(
udl int,
message *SMSMessage,
) error {
alphabet := dcs & 0x0c
alphabet := decodeSMSAlphabet(dcs)
expectedBytes := udl
if alphabet == 0 {
if alphabet == smsAlphabetGSM7 {
expectedBytes = (udl*7 + 7) / 8
}
if expectedBytes > len(data) {
@@ -848,8 +871,12 @@ func decodeUserData(
message.Concat = parseConcatHeader(data[1:headerBytes])
}
var header []byte
if headerBytes > 0 {
header = data[1:headerBytes]
}
switch alphabet {
case 0:
case smsAlphabetGSM7:
message.Encoding = SMSEncodingGSM7PDU
headerSeptets := 0
if headerBytes > 0 {
@@ -860,33 +887,228 @@ func decodeUserData(
if err != nil {
return err
}
text, err := decodeGSM7(septets)
text, err := decodeGSM7WithHeader(septets, header)
message.Text = text
return err
case 8:
case smsAlphabetUCS2:
message.Encoding = SMSEncodingUCS2PDU
payload := data[headerBytes:]
if len(payload)%2 != 0 {
return errors.New("UCS2 SMS has an odd byte count")
text, ok := decodeUTF16Bytes(payload)
if ok {
message.Text = text
return nil
}
units := make([]uint16, 0, len(payload)/2)
for index := 0; index < len(payload); index += 2 {
units = append(units, uint16(payload[index])<<8|uint16(payload[index+1]))
// Some gateways label UTF-8 or a local 8-bit character set as UCS-2.
// Only accept a fallback when it is unambiguously readable text.
if text, encoding, detected := decodeTextBytes(payload, header); detected {
message.Text = text
message.Encoding = encoding
return nil
}
message.Text = string(utf16.Decode(units))
return nil
return errors.New("UCS2 SMS has invalid UTF-16 data")
default:
// 8-bit (binary) user data has no portable text representation, so the
// raw payload bytes are rendered as uppercase hexadecimal after the user
// data header is stripped. This keeps the bubble non-empty and gives a
// faithful rendering of the delivered content rather than a blank "".
message.Encoding = SMSEncoding8BitPDU
payload := data[headerBytes:]
if text, encoding, detected := decodeTextBytes(payload, header); detected {
message.Text = text
message.Encoding = encoding
return nil
}
// Port-addressed or non-text 8-bit data remains hexadecimal, preserving
// binary SMS (WAP push, provisioning, SIM data) without lossy guessing.
message.Encoding = SMSEncoding8BitPDU
message.Text = strings.ToUpper(hex.EncodeToString(payload))
return nil
}
}
type smsAlphabet byte
const (
smsAlphabetGSM7 smsAlphabet = iota
smsAlphabet8Bit
smsAlphabetUCS2
smsAlphabetUnknown
)
// decodeSMSAlphabet applies the complete 3GPP TS 23.038 DCS grouping rules.
// A plain dcs&0x0c check is incorrect for message-waiting groups Cx/Dx/Ex and
// reserved coding groups, and can silently select the wrong decoder.
func decodeSMSAlphabet(dcs byte) smsAlphabet {
switch {
case dcs&0x80 == 0:
if dcs&0x20 != 0 { // GSM compression is not safely decodable here.
return smsAlphabetUnknown
}
switch (dcs >> 2) & 0x03 {
case 0:
return smsAlphabetGSM7
case 1:
return smsAlphabet8Bit
case 2:
return smsAlphabetUCS2
default:
return smsAlphabetUnknown
}
case dcs&0xe0 == 0xc0: // Cx and Dx message-waiting groups use GSM-7.
return smsAlphabetGSM7
case dcs&0xf0 == 0xe0: // Ex message-waiting group uses UCS-2.
return smsAlphabetUCS2
case dcs&0xf0 == 0xf0:
if dcs&0x04 != 0 {
return smsAlphabet8Bit
}
return smsAlphabetGSM7
default:
return smsAlphabetUnknown
}
}
func decodeGSM7WithHeader(septets, header []byte) (string, error) {
locking, hasLocking := userDataHeaderLanguage(header, 0x25)
shift, hasShift := userDataHeaderLanguage(header, 0x24)
if !hasLocking && !hasShift {
return decodeGSM7(septets)
}
options := make([]gsm7.DecoderOption, 0, 2)
if hasLocking {
options = append(options, gsm7.WithCharset(locking))
}
if hasShift {
options = append(options, gsm7.WithExtCharset(shift))
}
decoded, err := gsm7.Decode(septets, options...)
return string(decoded), err
}
func userDataHeaderLanguage(header []byte, identifier byte) (int, bool) {
for index := 0; index+1 < len(header); {
id := header[index]
length := int(header[index+1])
index += 2
if index+length > len(header) {
return 0, false
}
if id == identifier && length == 1 {
return int(header[index]), true
}
index += length
}
return 0, false
}
func decodeUTF16Bytes(payload []byte) (string, bool) {
if len(payload) == 0 {
return "", true
}
if len(payload)%2 != 0 {
return "", false
}
littleEndian := len(payload) >= 2 && payload[0] == 0xff && payload[1] == 0xfe
if (payload[0] == 0xfe && payload[1] == 0xff) || littleEndian {
payload = payload[2:]
}
units := make([]uint16, 0, len(payload)/2)
for index := 0; index < len(payload); index += 2 {
unit := uint16(payload[index])<<8 | uint16(payload[index+1])
if littleEndian {
unit = uint16(payload[index+1])<<8 | uint16(payload[index])
}
units = append(units, unit)
}
text := string(utf16.Decode(units))
return text, !strings.ContainsRune(text, unicode.ReplacementChar) && readableText(text)
}
func decodeTextBytes(payload, header []byte) (string, SMSEncoding, bool) {
if hasApplicationPortAddressing(header) || len(payload) == 0 {
return "", SMSEncoding8BitPDU, false
}
if len(payload) >= 2 && ((payload[0] == 0xfe && payload[1] == 0xff) ||
(payload[0] == 0xff && payload[1] == 0xfe)) {
if text, ok := decodeUTF16Bytes(payload); ok {
return text, SMSEncodingUCS2PDU, true
}
}
if utf8.Valid(payload) {
text := string(payload)
if readableText(text) {
return text, SMSEncodingUTF8PDU, true
}
}
if containsNonASCII(payload) {
decoded, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), payload)
text := string(decoded)
if err == nil && strings.ContainsFunc(text, func(character rune) bool {
return unicode.Is(unicode.Han, character)
}) && readableText(text) {
return text, SMSEncodingGB18030, true
}
}
if text, ok := decodeLatin1Text(payload); ok {
return text, SMSEncodingLatin1, true
}
return "", SMSEncoding8BitPDU, false
}
func readableText(text string) bool {
if text == "" {
return true
}
printable, total := 0, 0
for _, character := range text {
total++
if unicode.IsPrint(character) || character == '\n' || character == '\r' || character == '\t' {
printable++
}
}
return printable*100 >= total*90
}
func containsNonASCII(data []byte) bool {
for _, value := range data {
if value >= utf8.RuneSelf {
return true
}
}
return false
}
func decodeLatin1Text(payload []byte) (string, bool) {
characters := make([]rune, 0, len(payload))
ascii := 0
for _, value := range payload {
switch {
case value == '\n' || value == '\r' || value == '\t' || value >= 0x20 && value <= 0x7e:
ascii++
case value >= 0xa0:
default:
return "", false
}
characters = append(characters, rune(value))
}
if ascii == 0 || ascii*2 < len(payload) {
return "", false
}
text := string(characters)
return text, readableText(text)
}
func hasApplicationPortAddressing(header []byte) bool {
for index := 0; index+1 < len(header); {
identifier := header[index]
length := int(header[index+1])
index += 2
if index+length > len(header) {
return true
}
if (identifier == 0x04 && length == 2) || (identifier == 0x05 && length == 4) {
return true
}
index += length
}
return false
}
func parseConcatHeader(header []byte) *SMSConcatInfo {
for index := 0; index+1 < len(header); {
identifier := header[index]
+114
View File
@@ -1,6 +1,7 @@
package device
import (
"encoding/hex"
"errors"
"strings"
"testing"
@@ -323,6 +324,35 @@ func TestDecodeDeliverPDUWithAlphanumericSender(t *testing.T) {
}
}
func TestDecodeDeliverPDUWithShortStandardAlphanumericSender(t *testing.T) {
// TP-OA length is expressed in useful semi-octets. The three-character
// sender "OKX" therefore has length 6, even though it contains 3 septets.
// A previous short-address heuristic interpreted 6 as the character count
// and swallowed PID, DCS, and timestamp bytes into the sender address.
text := "Your OKX verification code is: 123456"
textSeptets, ok := encodeGSM7(text)
if !ok {
t.Fatal("test text is not GSM-7 encodable")
}
pdu := []byte{0x00, 0x04, 0x06, 0xd0}
pdu = append(pdu, packSeptets([]byte{'O', 'K', 'X'}, 0)...)
pdu = append(pdu,
0x00, 0x00, // PID and GSM-7 DCS.
0x62, 0x80, 0x20, 0x91, 0x40, 0x95, 0x00, // 2026-08-02 19:04:59 UTC.
byte(len(textSeptets)),
)
pdu = append(pdu, packSeptets(textSeptets, 0)...)
message, err := decodeSMSPDU(hex.EncodeToString(pdu))
if err != nil {
t.Fatalf("decode short alphanumeric sender: %v", err)
}
if message.From != "OKX" || message.Text != text ||
message.Encoding != SMSEncodingGSM7PDU || message.DataCodingScheme != 0 {
t.Fatalf("message = %#v", message)
}
}
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
@@ -340,3 +370,87 @@ func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
t.Fatalf("8-bit message = %#v", message)
}
}
func TestDecodeUserDataUnderstandsDCSGroups(t *testing.T) {
septets, ok := encodeGSM7("HELLO")
if !ok {
t.Fatal("encode GSM-7 test text")
}
packed := packSeptets(septets, 0)
for _, dcs := range []byte{0x00, 0xc8, 0xd0, 0xf0} {
message := SMSMessage{}
if err := decodeUserData(packed, 0, dcs, len(septets), &message); err != nil {
t.Fatalf("decode DCS 0x%02X: %v", dcs, err)
}
if message.Text != "HELLO" || message.Encoding != SMSEncodingGSM7PDU {
t.Fatalf("DCS 0x%02X message = %#v", dcs, message)
}
}
ucs2 := []byte{0x4f, 0x60, 0x59, 0x7d}
for _, dcs := range []byte{0x08, 0xe0} {
message := SMSMessage{}
if err := decodeUserData(ucs2, 0, dcs, len(ucs2), &message); err != nil {
t.Fatalf("decode DCS 0x%02X: %v", dcs, err)
}
if message.Text != "你好" || message.Encoding != SMSEncodingUCS2PDU {
t.Fatalf("DCS 0x%02X message = %#v", dcs, message)
}
}
}
func TestDecodeGSM7NationalLanguageTables(t *testing.T) {
// National language locking shift IEI 0x25, Turkish table 1. In that
// locking table septet 0x07 is the dotless i (ı), rather than default ì.
header := []byte{0x03, 0x25, 0x01, 0x01}
headerSeptets := (len(header)*8 + 6) / 7
data := packSeptets([]byte{0x07}, headerSeptets*7)
copy(data, header)
message := SMSMessage{}
if err := decodeUserData(data, 0x40, 0x00, headerSeptets+1, &message); err != nil {
t.Fatalf("decode Turkish locking table: %v", err)
}
if message.Text != "ı" || message.Encoding != SMSEncodingGSM7PDU {
t.Fatalf("message = %#v", message)
}
}
func TestDecode8BitTextEncodingsAndPreservesBinary(t *testing.T) {
tests := []struct {
name string
payload []byte
wantText string
encoding SMSEncoding
}{
{name: "UTF-8", payload: []byte("验证码 123456"), wantText: "验证码 123456", encoding: SMSEncodingUTF8PDU},
{name: "GB18030", payload: []byte{0xd1, 0xe9, 0xd6, 0xa4, 0xc2, 0xeb}, wantText: "验证码", encoding: SMSEncodingGB18030},
{name: "Latin-1", payload: []byte{'C', 'a', 'f', 0xe9}, wantText: "Café", encoding: SMSEncodingLatin1},
{name: "binary", payload: []byte{0xaa, 0xbb, 0xcc}, wantText: "AABBCC", encoding: SMSEncoding8BitPDU},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
message := SMSMessage{}
if err := decodeUserData(test.payload, 0, 0x04, len(test.payload), &message); err != nil {
t.Fatalf("decode: %v", err)
}
if message.Text != test.wantText || message.Encoding != test.encoding {
t.Fatalf("message = %#v", message)
}
})
}
}
func TestDecodePortAddressed8BitSMSRemainsBinary(t *testing.T) {
header := []byte{0x04, 0x04, 0x02, 0x0b, 0x84}
data := append(append([]byte(nil), header...), []byte("plain-looking payload")...)
message := SMSMessage{}
if err := decodeUserData(data, 0x40, 0x04, len(data), &message); err != nil {
t.Fatalf("decode: %v", err)
}
payload := data[len(header):]
if message.Text != strings.ToUpper(hex.EncodeToString(payload)) ||
message.Encoding != SMSEncoding8BitPDU {
t.Fatalf("message = %#v", message)
}
}
+3
View File
@@ -146,6 +146,9 @@ const (
SMSEncodingGSM7Text SMSEncoding = "gsm7_text"
SMSEncodingGSM7PDU SMSEncoding = "gsm7_pdu"
SMSEncodingUCS2PDU SMSEncoding = "ucs2_pdu"
SMSEncodingUTF8PDU SMSEncoding = "utf8_pdu"
SMSEncodingGB18030 SMSEncoding = "gb18030_pdu"
SMSEncodingLatin1 SMSEncoding = "latin1_pdu"
SMSEncoding8BitPDU SMSEncoding = "8bit_pdu"
SMSEncodingUnknown SMSEncoding = "unknown"
)
+57 -1
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"time"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
)
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error {
@@ -70,7 +72,7 @@ func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, prefe
func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
channel, openErr := session.OpenLogicalChannel(ctx, 1, aid)
channel, openErr := openNativeQMIChannelWithRecovery(ctx, session, 1, aid)
if openErr != nil {
return fmt.Errorf("open QMI UIM logical channel: %w", openErr)
}
@@ -102,6 +104,60 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai
return
}
type nativeQMIChannelRecoverySession interface {
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
PowerOffSIM(context.Context, uint8) error
PowerOnSIM(context.Context, uint8) error
}
// OpenStick 410 can leave the physical UICC powered but unable to allocate a
// logical channel after a SIM hot-swap. A UIM service reset alone does not
// clear that state; cycling the affected physical slot does. Recover only the
// precise QMI InsufficientResources response, then retry the original AID once.
func openNativeQMIChannelWithRecovery(
ctx context.Context,
session nativeQMIChannelRecoverySession,
slot uint8,
aid []byte,
) (byte, error) {
channel, err := session.OpenLogicalChannel(ctx, slot, aid)
if err == nil || !isQMIInsufficientResources(err) {
return channel, err
}
if resetter, ok := session.(nativeQMIUIMResetSession); ok {
_ = resetter.ResetUIM(ctx)
}
if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
if powerErr := session.PowerOnSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
return session.OpenLogicalChannel(ctx, slot, aid)
}
func isQMIInsufficientResources(err error) bool {
qmiErr := qmi.GetQMIError(err)
return qmiErr != nil && qmiErr.Service == qmi.ServiceUIM && qmiErr.ErrorCode == 0x0044
}
var waitNativeQMIRecovery = func(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
qmiMode, modeErr := session.GetOperatingMode(ctx)
+23 -3
View File
@@ -9,9 +9,9 @@ import (
"time"
)
// Entry is the stable, secret-neutral representation exposed by the log API.
// Callers remain responsible for never adding credentials or keying material
// to slog attributes.
// Entry is the stable, centrally-redacted representation exposed by the log
// API. The Hub sanitizes both the downstream handler and the captured entry so
// diagnostic logs can be safely exported by users.
type Entry struct {
Time time.Time `json:"time"`
Level string `json:"level"`
@@ -58,6 +58,7 @@ func (h *Hub) Enabled(ctx context.Context, level slog.Level) bool {
}
func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
record = sanitizeRecord(record)
err := h.next.Handle(ctx, record)
fields := make(map[string]any)
for _, attr := range h.attrs {
@@ -81,6 +82,7 @@ func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
}
func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
attrs = sanitizeAttrs(attrs)
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
return &Hub{
next: h.next.WithAttrs(attrs),
@@ -180,6 +182,24 @@ func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) {
return channel, cancel
}
// Clear drops captured history and every entry currently queued for live and
// persistence subscribers. Subscribers stay connected for future events.
func (h *Hub) Clear() {
h.core.mu.Lock()
h.core.entries = h.core.entries[:0]
for _, subscriber := range h.core.subscribers {
for {
select {
case <-subscriber:
continue
default:
}
break
}
}
h.core.mu.Unlock()
}
func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
+74
View File
@@ -1,9 +1,12 @@
package loghub
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
)
@@ -29,6 +32,51 @@ func TestHubHistoryFiltersAndBounds(t *testing.T) {
}
}
func TestHubRedactsDownstreamAndHistoryAndPreservesErrors(t *testing.T) {
var output bytes.Buffer
hub := New(slog.NewJSONHandler(&output, nil), 100)
logger := slog.New(hub).With("imsi", "234159611634973")
logger.Warn(
"delivery to +447700900123 failed",
"iccid", "8944101234567890123",
"peer", "+447700900456",
"error", errors.New("modem rejected MSISDN=447700900789 with +CMS ERROR: 305"),
)
entry := hub.History(1, slog.LevelDebug, "")[0]
if strings.Contains(entry.Message, "447700900123") {
t.Fatalf("message was not redacted: %q", entry.Message)
}
for _, key := range []string{"imsi", "iccid", "peer"} {
if value := entry.Fields[key]; !strings.Contains(value.(string), "REDACTED") {
t.Fatalf("%s = %#v, want redacted", key, value)
}
}
errorText, ok := entry.Fields["error"].(string)
if !ok || !strings.Contains(errorText, "+CMS ERROR: 305") || strings.Contains(errorText, "447700900789") {
t.Fatalf("error = %#v, want original modem error with identity redacted", entry.Fields["error"])
}
if raw := output.String(); strings.Contains(raw, "234159611634973") || strings.Contains(raw, "447700900") {
t.Fatalf("downstream output leaked an identity: %s", raw)
}
}
func TestSanitizeEntryProtectsLegacyNestedFields(t *testing.T) {
entry := SanitizeEntry(Entry{
Message: "incoming SIP from sip:[email protected]",
Fields: map[string]any{
"details": map[string]any{"associated_number": "+447700900456", "status": "registered"},
},
})
if strings.Contains(entry.Message, "447700900123") {
t.Fatalf("message = %q", entry.Message)
}
details := entry.Fields["details"].(map[string]any)
if strings.Contains(details["associated_number"].(string), "447700900456") {
t.Fatalf("nested field leaked: %#v", details)
}
}
func TestHubSubscription(t *testing.T) {
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
entries, cancel := hub.Subscribe(1)
@@ -47,3 +95,29 @@ func TestHubSubscription(t *testing.T) {
t.Fatal("timed out waiting for log entry")
}
}
func TestHubClearDropsHistoryAndQueuedEntries(t *testing.T) {
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
entries, cancel := hub.Subscribe(4)
defer cancel()
logger := slog.New(hub)
logger.Info("before clear")
hub.Clear()
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("history after Clear = %#v", history)
}
select {
case entry := <-entries:
t.Fatalf("queued entry survived Clear: %#v", entry)
default:
}
logger.Info("after clear")
select {
case entry := <-entries:
if entry.Message != "after clear" {
t.Fatalf("entry = %#v", entry)
}
case <-time.After(time.Second):
t.Fatal("subscriber did not remain active after Clear")
}
}
+215
View File
@@ -0,0 +1,215 @@
package loghub
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"regexp"
"strings"
"time"
"unicode"
)
var (
sipIdentityPattern = regexp.MustCompile(`(?i)\b(sips?|tel):([^@;>,\s]+)(@[^;>,\s]+)?`)
internationalPhonePattern = regexp.MustCompile(`(?:\+|00)[0-9][0-9 ()-]{5,}[0-9]`)
longDigitsPattern = regexp.MustCompile(`\b[0-9]{7,22}\b`)
labeledIdentityPattern = regexp.MustCompile(`(?i)\b(iccid|imsi|msisdn|imei|eid)\s*([=:])\s*([a-z0-9+_-]{7,})`)
)
// IsHTTPAccessEntry identifies legacy request-traffic entries. Access traffic
// is intentionally excluded from the user diagnostic log surface.
func IsHTTPAccessEntry(entry Entry) bool {
if strings.EqualFold(strings.TrimSpace(entry.Message), "http request") {
return true
}
category, _ := entry.Fields["category"].(string)
return strings.EqualFold(strings.TrimSpace(category), "http_access")
}
// SanitizeEntry also protects records that were persisted by an older build
// before central redaction was introduced.
func SanitizeEntry(entry Entry) Entry {
entry.Message = RedactString(entry.Message)
entry.Caller = RedactString(entry.Caller)
if entry.Fields != nil {
entry.Fields = sanitizeMap(entry.Fields)
}
return entry
}
// RedactString masks common telecom identities while retaining enough of the
// suffix to correlate repeated events in an exported diagnostic log.
func RedactString(value string) string {
if value == "" {
return value
}
value = labeledIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
parts := labeledIdentityPattern.FindStringSubmatch(match)
return parts[1] + parts[2] + maskToken(parts[3])
})
value = sipIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
parts := sipIdentityPattern.FindStringSubmatch(match)
domain := parts[3]
return parts[1] + ":" + maskToken(parts[2]) + domain
})
value = internationalPhonePattern.ReplaceAllStringFunc(value, maskToken)
return longDigitsPattern.ReplaceAllStringFunc(value, maskToken)
}
func sanitizeRecord(record slog.Record) slog.Record {
clean := slog.NewRecord(record.Time, record.Level, RedactString(record.Message), record.PC)
record.Attrs(func(attr slog.Attr) bool {
clean.AddAttrs(sanitizeAttr(attr))
return true
})
return clean
}
func sanitizeAttrs(attrs []slog.Attr) []slog.Attr {
clean := make([]slog.Attr, 0, len(attrs))
for _, attr := range attrs {
clean = append(clean, sanitizeAttr(attr))
}
return clean
}
func sanitizeAttr(attr slog.Attr) slog.Attr {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return attr
}
if sensitiveKey(attr.Key) {
return slog.String(attr.Key, maskToken(valueText(attr.Value.Any())))
}
if attr.Value.Kind() == slog.KindGroup {
children := attr.Value.Group()
return slog.Group(attr.Key, attrsToAny(sanitizeAttrs(children))...)
}
switch attr.Value.Kind() {
case slog.KindString:
return slog.String(attr.Key, RedactString(attr.Value.String()))
case slog.KindAny:
return slog.Any(attr.Key, sanitizeAny(attr.Value.Any(), attr.Key))
default:
return attr
}
}
func attrsToAny(attrs []slog.Attr) []any {
values := make([]any, len(attrs))
for index := range attrs {
values[index] = attrs[index]
}
return values
}
func sanitizeAny(value any, key string) any {
if value == nil {
return nil
}
if sensitiveKey(key) {
return maskToken(valueText(value))
}
switch typed := value.(type) {
case error:
return RedactString(typed.Error())
case string:
return RedactString(typed)
case []byte:
return RedactString(string(typed))
case json.RawMessage:
var decoded any
if json.Unmarshal(typed, &decoded) == nil {
return sanitizeAny(decoded, key)
}
return RedactString(string(typed))
case map[string]any:
return sanitizeMap(typed)
case []any:
result := make([]any, len(typed))
for index := range typed {
result[index] = sanitizeAny(typed[index], key)
}
return result
case time.Time, time.Duration:
return value
}
rv := reflect.ValueOf(value)
if rv.IsValid() && (rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct || rv.Kind() == reflect.Pointer) {
if raw, err := json.Marshal(value); err == nil {
var decoded any
if json.Unmarshal(raw, &decoded) == nil {
return sanitizeAny(decoded, key)
}
}
}
if stringer, ok := value.(fmt.Stringer); ok {
return RedactString(stringer.String())
}
return value
}
func sanitizeMap(source map[string]any) map[string]any {
result := make(map[string]any, len(source))
for key, value := range source {
result[key] = sanitizeAny(value, key)
}
return result
}
func sensitiveKey(key string) bool {
normalized := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return unicode.ToLower(r)
}
return -1
}, key)
if strings.Contains(normalized, "password") || strings.Contains(normalized, "passwd") ||
strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") ||
strings.Contains(normalized, "cookie") || strings.Contains(normalized, "authorization") ||
strings.Contains(normalized, "privateidentity") || strings.Contains(normalized, "publicidentity") ||
strings.Contains(normalized, "associatednumber") || strings.Contains(normalized, "sipuri") {
return true
}
switch normalized {
case "iccid", "imsi", "imei", "eid", "supi", "suci", "msisdn", "phone", "phonenumber",
"number", "caller", "called", "callee", "recipient", "peer", "from", "to":
return true
default:
return false
}
}
func valueText(value any) string {
if value == nil {
return ""
}
if err, ok := value.(error); ok {
return err.Error()
}
return fmt.Sprint(value)
}
func maskToken(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "[REDACTED]"
}
runes := []rune(value)
digits := make([]rune, 0, 4)
for index := len(runes) - 1; index >= 0 && len(digits) < 4; index-- {
if unicode.IsDigit(runes[index]) {
digits = append(digits, runes[index])
}
}
if len(digits) == 0 {
return "[REDACTED]"
}
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
digits[left], digits[right] = digits[right], digits[left]
}
return "[REDACTED:" + string(digits) + "]"
}
+33 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
@@ -21,6 +22,20 @@ func (s *Server) recordAudit(
outcome string,
remoteAddr string,
) {
level := slog.LevelInfo
if !strings.EqualFold(strings.TrimSpace(outcome), "success") {
level = slog.LevelWarn
}
if s.logger != nil {
s.logger.Log(ctx, level, "user operation",
"category", auditLogCategory(action),
"event", action,
"actor", actor,
"entity_type", entityType,
"entity_id", entityID,
"outcome", outcome,
)
}
if s.store == nil {
return
}
@@ -34,7 +49,24 @@ func (s *Server) recordAudit(
CreatedAt: time.Now().UTC(),
})
if err != nil {
s.logger.Warn("write audit event failed", "action", action, "error", err)
s.logger.Warn("write audit event failed", "category", "system", "action", action, "raw_error", err)
}
}
func auditLogCategory(action string) string {
action = strings.ToLower(strings.TrimSpace(action))
switch {
case strings.Contains(action, ".sms") || strings.HasPrefix(action, "sms."):
return "sms"
case strings.Contains(action, ".call") || strings.HasPrefix(action, "call."):
return "call"
case strings.Contains(action, "vowifi") || strings.Contains(action, "ims"):
return "vowifi"
case strings.Contains(action, "device") || strings.Contains(action, "esim") ||
strings.Contains(action, ".at.") || strings.Contains(action, ".ussd"):
return "hardware"
default:
return "operation"
}
}
+10
View File
@@ -130,6 +130,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
}
}
if err != nil {
s.logger.Warn("VoWiFi call operation failed",
"category", "call", "event", "call."+action,
"device_id", config.ID, "number", number, "call_id", callID,
"transport", transport, "raw_error", err,
)
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
return true
}
@@ -156,6 +161,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
return true
}
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
s.logger.Warn("cellular call operation rejected",
"category", "call", "event", "call."+action,
"device_id", config.ID, "number", number, "transport", transport,
"modem_final", response.Final, "raw_response", response.Text(),
)
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
return true
}
+23 -7
View File
@@ -98,11 +98,21 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
if notification.Time.IsZero() {
notification.Time = time.Now().UTC()
}
if s.logger != nil {
s.logger.Info("incoming call detected",
"category", "call",
"event", "call.incoming",
"device_id", notification.DeviceID,
"caller", notification.Caller,
"called", notification.Called,
"transport", notification.Environment,
)
}
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
if s.logger != nil {
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
s.logger.Debug("suppressed duplicate incoming call notification", "category", "call", "device_id", notification.DeviceID, "caller", notification.Caller)
}
return
}
@@ -137,7 +147,7 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
}
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
if s.logger != nil {
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
s.logger.Warn("send incoming call notification", "category", "call", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "raw_error", err)
}
}
}
@@ -315,11 +325,7 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
}
calls := parseCLCC(response)
for _, call := range calls {
direction, _ := call["direction"].(int)
state, _ := call["state"].(int)
// direction 1 = incoming (Mobile Terminated)
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
if isIncomingVoiceCLCC(call) {
caller, _ := call["number"].(string)
if caller == "" {
caller = "未知号码"
@@ -341,3 +347,13 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
}
}
}
func isIncomingVoiceCLCC(call map[string]any) bool {
direction, _ := call["direction"].(int)
state, _ := call["state"].(int)
mode, _ := call["mode"].(int)
// direction 1 = incoming (Mobile Terminated)
// mode 0 = voice; some modems also expose packet-data sessions as CLCC mode 1
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
return direction == 1 && mode == 0 && (state == 4 || state == 5 || state == 0 || state == 3)
}
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"vocat/internal/modem"
)
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
@@ -61,6 +63,56 @@ func TestIncomingCallDeduplication(t *testing.T) {
}
}
func TestIncomingVoiceCLCCIgnoresDataSessions(t *testing.T) {
tests := []struct {
name string
call map[string]any
want bool
}{
{
name: "incoming voice ringing",
call: map[string]any{"direction": 1, "state": 4, "mode": 0},
want: true,
},
{
name: "incoming voice active",
call: map[string]any{"direction": 1, "state": 0, "mode": 0},
want: true,
},
{
name: "incoming packet data active",
call: map[string]any{"direction": 1, "state": 0, "mode": 1},
want: false,
},
{
name: "outgoing voice alerting",
call: map[string]any{"direction": 0, "state": 3, "mode": 0},
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isIncomingVoiceCLCC(test.call); got != test.want {
t.Fatalf("isIncomingVoiceCLCC() = %v, want %v", got, test.want)
}
})
}
// EC20/EC25 firmware may expose an active packet-data session in CLCC.
// It must not be treated as an incoming voice call.
dataCalls := parseCLCC(modem.Response{
Lines: []string{`+CLCC: 1,1,0,1,0,"",128`},
Final: "OK",
})
if len(dataCalls) != 1 {
t.Fatalf("parseCLCC() returned %d data calls, want 1", len(dataCalls))
}
if isIncomingVoiceCLCC(dataCalls[0]) {
t.Fatal("active packet-data CLCC record was treated as an incoming voice call")
}
}
func TestRenderCallWebhookTemplate(t *testing.T) {
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
message := IncomingCallNotification{
+17 -4
View File
@@ -1002,6 +1002,11 @@ func (s *Server) handleVoWiFiReconnect(
}
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
s.logger.Warn("VoWiFi operation failed",
"category", "vowifi",
"event", "vowifi.operation_failed",
"raw_error", err,
)
switch {
case errors.Is(err, vowifiruntime.ErrNotRegistered):
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime")
@@ -1012,7 +1017,6 @@ func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
case errors.Is(err, vowifi.ErrNotRunning):
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
default:
s.logger.Warn("VoWiFi action rejected", "error", err)
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error())
}
}
@@ -1070,6 +1074,13 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
text += "\n"
}
text += commandErr.Final
s.logger.Warn("AT command rejected by modem",
"category", "hardware",
"event", "hardware.at_rejected",
"device_id", id,
"modem_final", commandErr.Final,
"raw_response", text,
)
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"response": text,
@@ -1509,6 +1520,11 @@ func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool
}
func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
s.logger.Warn("hardware operation failed",
"category", "hardware",
"event", "hardware.operation_failed",
"raw_error", device.HardwareErrorDetail(err),
)
switch {
case errors.Is(err, device.ErrNotFound):
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present")
@@ -1547,9 +1563,6 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
case errors.Is(err, context.Canceled):
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
default:
// Preserve the hardware failure reason in the operator-visible log while
// keeping AT payloads and long APDU material out of it.
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
}
}
+31 -5
View File
@@ -152,7 +152,24 @@ func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
if r.Method == http.MethodDelete {
clearedAt := time.Now().UTC()
if s.logs != nil {
s.logs.Clear()
}
deleted, err := s.store.ClearLogEvents(r.Context(), clearedAt)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{"cleared": true, "deleted": deleted},
})
return
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
@@ -170,7 +187,9 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
// backs the live stream).
entries := []loghub.Entry{}
if s.store != nil {
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit})
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{
Limit: limit, ExcludeMessage: "http request",
})
if err != nil {
s.writeStoreError(w, err)
return
@@ -179,11 +198,17 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
if storedLogLevel(event.Level) < minimum {
continue
}
entry := storedLogToEntry(event)
entry := loghub.SanitizeEntry(storedLogToEntry(event))
if loghub.IsHTTPAccessEntry(entry) {
continue
}
if search != "" && !storedLogContains(entry, search) {
continue
}
entries = append(entries, entry)
if len(entries) == limit {
break
}
}
// ListLogEvents is newest-first; present chronologically.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
@@ -283,7 +308,8 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if logLevel(entry.Level) < minimum {
entry = loghub.SanitizeEntry(entry)
if loghub.IsHTTPAccessEntry(entry) || logLevel(entry.Level) < minimum {
continue
}
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
@@ -308,7 +334,7 @@ func logLevel(value string) slog.Level {
return slog.LevelError
case "warn", "warning":
return slog.LevelWarn
case "debug":
case "debug", "all", "":
return slog.LevelDebug
default:
return slog.LevelInfo
+40
View File
@@ -0,0 +1,40 @@
package server
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"vocat/internal/loghub"
"vocat/internal/store"
)
func TestHandleLogHistoryDeleteClearsMemoryAndDatabase(t *testing.T) {
server := newSettingsTestServer(t)
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
server.logs = hub
server.logger = slog.New(hub)
server.logger.Info("memory log")
if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{
Level: "info", Message: "persisted log",
}); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil)
server.handleLogHistory(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("memory history after clear = %#v", history)
}
count, err := server.store.CountLogEvents(context.Background())
if err != nil || count != 0 {
t.Fatalf("persisted count after clear = %d, %v", count, err)
}
}
+13 -3
View File
@@ -36,13 +36,17 @@ func parseLoggingConfig(config loggingConfig) (loggingConfig, error) {
if config.Count < 1 {
config.Count = 10000
}
if config.Count > store.MaxLogEvents {
config.Count = store.MaxLogEvents
}
if config.Days < 1 {
config.Days = 30
}
return config, nil
}
// loadLoggingConfig reads the persisted retention policy, defaulting to unlimited.
// loadLoggingConfig reads the persisted retention policy. "unlimited" means
// no user-selected limit below the global 10,000-row hard ceiling.
func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
config := defaultLoggingConfig()
setting, err := s.store.AppSetting(ctx, loggingSettingKey)
@@ -64,13 +68,17 @@ func (s *Server) applyLogRetention(ctx context.Context) error {
switch config.Mode {
case "days":
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour)
_, err := s.store.PruneLogEvents(ctx, cutoff)
if _, err := s.store.PruneLogEvents(ctx, cutoff); err != nil {
return err
}
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
return err
case "count":
_, err := s.store.PruneLogEventsToCount(ctx, config.Count)
return err
default:
return nil
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
return err
}
}
@@ -116,6 +124,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
"count": config.Count,
"days": config.Days,
"stored_logs": stored,
"max_logs": store.MaxLogEvents,
},
})
case http.MethodPut:
@@ -152,6 +161,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
"count": config.Count,
"days": config.Days,
"stored_logs": stored,
"max_logs": store.MaxLogEvents,
},
})
default:
+10
View File
@@ -199,6 +199,16 @@ func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) {
}
}
func TestLoggingCountIsClampedToHardLimit(t *testing.T) {
config, err := parseLoggingConfig(loggingConfig{Mode: "count", Count: store.MaxLogEvents + 500})
if err != nil {
t.Fatal(err)
}
if config.Count != store.MaxLogEvents {
t.Fatalf("count = %d, want %d", config.Count, store.MaxLogEvents)
}
}
func TestLoginLockoutViaHTTP(t *testing.T) {
app := newTestApplication(t)
for i := 0; i < 4; i++ {
+47 -14
View File
@@ -160,7 +160,7 @@ func New(options Options) (*Server, error) {
mux.HandleFunc("/", server.handleSPA)
server.handler = server.recoverPanics(
server.securityHeaders(server.accessControl(server.logRequests(mux))),
server.securityHeaders(server.accessControl(server.logUserOperation(mux))),
)
return server, nil
}
@@ -557,16 +557,14 @@ func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool
return false
}
type statusWriter struct {
type operationStatusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func (w *operationStatusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *statusWriter) WriteHeader(status int) {
func (w *operationStatusWriter) WriteHeader(status int) {
if w.status != 0 {
return
}
@@ -574,25 +572,60 @@ func (w *statusWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status)
}
func (s *Server) logRequests(next http.Handler) http.Handler {
// logUserOperation records state-changing API actions, not request traffic.
// GET/HEAD polling, assets, health checks and the live log stream are never
// emitted, keeping the diagnostic page focused on actions a user initiated.
func (s *Server) logUserOperation(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
writer := &statusWriter{ResponseWriter: w}
if !strings.HasPrefix(r.URL.Path, "/api/") ||
r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions ||
strings.HasPrefix(r.URL.Path, "/api/auth/") || strings.HasPrefix(r.URL.Path, "/api/logs/") {
next.ServeHTTP(w, r)
return
}
writer := &operationStatusWriter{ResponseWriter: w}
next.ServeHTTP(writer, r)
status := writer.status
if status == 0 {
status = http.StatusOK
}
s.logger.Info(
"http request",
"method", r.Method,
"path", r.URL.Path,
level := slog.LevelInfo
outcome := "success"
message := "user operation completed"
if status >= http.StatusBadRequest {
level = slog.LevelWarn
outcome = "failed"
message = "user operation failed"
}
s.logger.Log(r.Context(), level, message,
"category", operationPathCategory(r.URL.Path),
"event", "user.operation",
"operation", strings.TrimPrefix(r.URL.Path, "/api/"),
"outcome", outcome,
"status", status,
"duration", time.Since(startedAt),
)
})
}
func operationPathCategory(path string) string {
path = strings.ToLower(path)
switch {
case strings.Contains(path, "/sms"):
return "sms"
case strings.Contains(path, "/call"):
return "call"
case strings.Contains(path, "/vowifi") || strings.Contains(path, "/ims"):
return "vowifi"
case strings.Contains(path, "/network") || strings.Contains(path, "/operator"):
return "network"
case strings.Contains(path, "/device") || strings.Contains(path, "/esim") ||
strings.Contains(path, "/ussd") || strings.Contains(path, "/at"):
return "hardware"
default:
return "operation"
}
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
+28
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"io/fs"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
@@ -18,9 +19,36 @@ import (
"golang.org/x/crypto/bcrypt"
"vocat/internal/auth"
"vocat/internal/loghub"
"vocat/internal/store"
)
func TestUserOperationLoggerExcludesReadTraffic(t *testing.T) {
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
server := &Server{logger: slog.New(hub)}
handler := server.logUserOperation(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/devices", nil))
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
t.Fatalf("GET traffic produced diagnostic logs: %#v", history)
}
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPatch, "/api/devices/dev1/network", nil))
history := hub.History(10, slog.LevelDebug, "")
if len(history) != 1 {
t.Fatalf("mutation log count = %d, want 1", len(history))
}
if history[0].Message != "user operation completed" || history[0].Fields["category"] != "network" {
t.Fatalf("mutation log = %#v", history[0])
}
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil))
if history = hub.History(10, slog.LevelDebug, ""); len(history) != 1 {
t.Fatalf("log clear endpoint produced an operation log: %#v", history)
}
}
type testApplication struct {
server *httptest.Server
client *http.Client
+56 -4
View File
@@ -369,16 +369,26 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
if sendErr != nil {
data["retry_safe"] = false
if result.PartsAccepted > 0 {
s.logger.Warn("multipart SMS was only partially accepted",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "parts_attempted", result.PartsAttempted,
"parts_accepted", result.PartsAccepted, "raw_error", sendErr,
)
data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
return
}
s.logger.Warn(
"SMS submission failed after modem interaction",
"category", "sms",
"event", "sms.submission",
"device_id", request.DeviceID,
"peer", request.Phone,
"transport", "cellular_at",
"parts_attempted", result.PartsAttempted,
"parts_accepted", result.PartsAccepted,
"error", sendErr,
"raw_error", sendErr,
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{
@@ -390,6 +400,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
return
}
if !result.AllPartsAccepted {
s.logger.Warn("SMS submission was not confirmed",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "modem_final", result.ModemFinal,
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{
Code: "sms_submission_unconfirmed",
@@ -399,6 +415,11 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
})
return
}
s.logger.Info("SMS submission accepted",
"category", "sms", "event", "sms.submission",
"device_id", request.DeviceID, "peer", request.Phone,
"transport", "cellular_at", "parts", result.PartsAccepted,
)
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
}
@@ -475,6 +496,12 @@ func (s *Server) writeIMSSMSSendResult(
"outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
}
if sendErr != nil {
s.logger.Warn("IMS SMS submission failed",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
"raw_error", sendErr,
)
data["retry_safe"] = false
data["warning"] = sendErr.Error()
if result.PartsAccepted == 0 {
@@ -489,6 +516,11 @@ func (s *Server) writeIMSSMSSendResult(
}
}
if !result.AllPartsAccepted && result.PartsAccepted == 0 {
s.logger.Warn("IMS SMS submission was not confirmed",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": apiError{
Code: "ims_sms_submission_unconfirmed",
@@ -498,6 +530,19 @@ func (s *Server) writeIMSSMSSendResult(
})
return
}
if result.AllPartsAccepted {
s.logger.Info("IMS SMS submission accepted",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts", result.PartsAccepted,
)
} else {
s.logger.Warn("multipart IMS SMS was only partially accepted",
"category", "sms", "event", "sms.submission",
"device_id", deviceID, "peer", result.To, "transport", "ims",
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
)
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
}
@@ -653,7 +698,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
"delivery_status": message.DeliveryStatus,
"data_coding_scheme": message.DataCodingScheme,
})
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
saved, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
MessageID: messageID,
DeviceID: config.ID,
ModemIMEI: modemIMEI,
@@ -670,7 +715,14 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
Extra: extra,
})
if saveErr != nil {
s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr)
s.logger.Warn("persist modem SMS failed", "category", "sms", "device_id", config.ID, "raw_error", saveErr)
} else if saved.Direction == "inbound" && saved.CreatedAt.Unix() == saved.UpdatedAt.Unix() {
s.logger.Info("cellular SMS received",
"category", "sms", "event", "sms.received",
"device_id", config.ID, "peer", saved.Peer,
"transport", "cellular_at", "encoding", message.Encoding,
"parts", saved.PartsTotal,
)
}
}
}
@@ -770,6 +822,6 @@ func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
return
}
s.logger.Error("database operation failed", "error", err)
s.logger.Error("database operation failed", "category", "system", "event", "store.operation_failed", "raw_error", err)
writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
}
+9
View File
@@ -1017,6 +1017,15 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("log filter result = %+v, %v", logs, err)
}
if _, err := database.AppendLogEvent(ctx, LogEvent{
Time: recent, Level: "info", Message: " HTTP REQUEST ",
}); err != nil {
t.Fatal(err)
}
logs, err = database.ListLogEvents(ctx, LogFilter{Level: "info", ExcludeMessage: "http request"})
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
t.Fatalf("excluded log filter result = %+v, %v", logs, err)
}
auditDeleted, logDeleted, err := database.PruneEvents(
ctx,
old.Add(time.Minute),
+56 -2
View File
@@ -9,6 +9,10 @@ import (
"time"
)
// MaxLogEvents is the hard storage ceiling. Every new row beyond this limit
// replaces the oldest row regardless of the optional, stricter retention rule.
const MaxLogEvents = 10000
func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
value.Action = strings.TrimSpace(value.Action)
if value.Action == "" {
@@ -123,6 +127,8 @@ func auditEvent(row rowScanner) (AuditEvent, error) {
}
func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) {
s.logMu.Lock()
defer s.logMu.Unlock()
value.Level = strings.ToLower(strings.TrimSpace(value.Level))
if value.Level == "" {
return LogEvent{}, errors.New("log level is required")
@@ -137,7 +143,18 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
if value.Time.IsZero() {
value.Time = time.Now().UTC()
}
result, err := s.db.ExecContext(ctx, `
value.Fields = fields
if !s.logClearedAt.IsZero() && !value.Time.After(s.logClearedAt) {
// The entry was queued before a user cleared the log. Silently discard it
// so an in-flight persistence worker cannot resurrect cleared history.
return value, nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return LogEvent{}, fmt.Errorf("begin log append: %w", err)
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
INSERT INTO log_events (event_time, level, message, caller, fields_json)
VALUES (?, ?, ?, ?, ?)
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields))
@@ -148,7 +165,17 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
if err != nil {
return LogEvent{}, fmt.Errorf("read log event id: %w", err)
}
value.Fields = fields
if _, err := tx.ExecContext(ctx, `
DELETE FROM log_events
WHERE id <= COALESCE((
SELECT id FROM log_events ORDER BY id DESC LIMIT 1 OFFSET ?
), 0)
`, MaxLogEvents); err != nil {
return LogEvent{}, fmt.Errorf("enforce log event limit: %w", err)
}
if err := tx.Commit(); err != nil {
return LogEvent{}, fmt.Errorf("commit log append: %w", err)
}
return value, nil
}
@@ -159,6 +186,10 @@ func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent
clauses = append(clauses, `level = ?`)
args = append(args, strings.ToLower(filter.Level))
}
if filter.ExcludeMessage != "" {
clauses = append(clauses, `LOWER(TRIM(message)) <> ?`)
args = append(args, strings.ToLower(strings.TrimSpace(filter.ExcludeMessage)))
}
if !filter.Since.IsZero() {
clauses = append(clauses, `event_time >= ?`)
args = append(args, filter.Since.UTC().Unix())
@@ -236,6 +267,29 @@ func (s *Store) CountLogEvents(ctx context.Context) (int64, error) {
return count, nil
}
// ClearLogEvents permanently removes all persisted logs. Entries timestamped
// at or before clearedAt are also rejected if they were already queued by the
// asynchronous persistence worker.
func (s *Store) ClearLogEvents(ctx context.Context, clearedAt time.Time) (int64, error) {
s.logMu.Lock()
defer s.logMu.Unlock()
if clearedAt.IsZero() {
clearedAt = time.Now().UTC()
}
result, err := s.db.ExecContext(ctx, `DELETE FROM log_events`)
if err != nil {
return 0, fmt.Errorf("clear log events: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("read cleared log count: %w", err)
}
if clearedAt.After(s.logClearedAt) {
s.logClearedAt = clearedAt
}
return affected, nil
}
// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
// rest. keep <= 0 deletes everything.
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) {
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"context"
"fmt"
"testing"
"time"
)
func TestAppendLogEventEnforcesHardLimit(t *testing.T) {
database, err := Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
defer database.Close()
if _, err := database.db.ExecContext(context.Background(), `
WITH RECURSIVE sequence(value) AS (
SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value <= ?
)
INSERT INTO log_events(event_time, level, message, caller, fields_json)
SELECT value, 'info', 'seed-' || value, '', '{}' FROM sequence
`, MaxLogEvents); err != nil {
t.Fatal(err)
}
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "newest", Time: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
count, err := database.CountLogEvents(context.Background())
if err != nil || count != MaxLogEvents {
t.Fatalf("CountLogEvents = %d, %v; want %d", count, err, MaxLogEvents)
}
logs, err := database.ListLogEvents(context.Background(), LogFilter{Limit: 1})
if err != nil || len(logs) != 1 || logs[0].Message != "newest" {
t.Fatalf("newest log = %#v, %v", logs, err)
}
}
func TestClearLogEventsRejectsAlreadyQueuedEntries(t *testing.T) {
database, err := Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
defer database.Close()
cutoff := time.Now().UTC()
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "existing", Time: cutoff.Add(-time.Second),
}); err != nil {
t.Fatal(err)
}
deleted, err := database.ClearLogEvents(context.Background(), cutoff)
if err != nil || deleted != 1 {
t.Fatalf("ClearLogEvents = %d, %v", deleted, err)
}
late, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: "queued-before-clear", Time: cutoff.Add(-time.Millisecond),
})
if err != nil || late.ID != 0 {
t.Fatalf("old queued append = %+v, %v", late, err)
}
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
Level: "info", Message: fmt.Sprintf("new-%d", MaxLogEvents), Time: cutoff.Add(time.Millisecond),
}); err != nil {
t.Fatal(err)
}
count, err := database.CountLogEvents(context.Background())
if err != nil || count != 1 {
t.Fatalf("CountLogEvents = %d, %v; want 1", count, err)
}
}
+6 -5
View File
@@ -467,11 +467,12 @@ type LogEvent struct {
}
type LogFilter struct {
Level string
Since time.Time
Until time.Time
BeforeID int64
Limit int
Level string
ExcludeMessage string
Since time.Time
Until time.Time
BeforeID int64
Limit int
}
type CardPolicy struct {
+4 -1
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
@@ -19,7 +20,9 @@ var ErrNotFound = errors.New("store: not found")
// Store owns the SQLite connection used by the process.
type Store struct {
db *sql.DB
db *sql.DB
logMu sync.Mutex
logClearedAt time.Time
}
type Admin struct {
+33 -19
View File
@@ -42,8 +42,10 @@ type CarrierProfile struct {
IMSRegisterProfile string
IMSIPSecEncryption string
SMSCenter string
PANIEnabled *bool
PANICountry string
PANINode string
IMSUserAgent string
IMSDialURIScheme string
IMSUserEqPhone bool
IMSVoiceCodecs []string
@@ -57,7 +59,6 @@ type IMSRegisterOptions struct {
ContactExtraTags []string
SupportedHeader *string
AllowHeader *string
UserAgent string
PPreferredIdentity bool
PVisitedNetworkID string
PAccessNetworkInfo *string
@@ -68,6 +69,7 @@ type IMSRegisterOptions struct {
const (
IMSContactFormatStandard = "standard"
IMSContactFormatATT = "att"
IMSContactFormatGSMA = "gsma"
)
type carrierProfileDocument struct {
@@ -76,13 +78,13 @@ type carrierProfileDocument struct {
}
type carrierProfileRule struct {
ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"`
ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"`
}
type carrierProfileMatch struct {
@@ -116,8 +118,10 @@ type carrierProfileIMS struct {
RegisterProfile string `json:"register_profile,omitempty"`
IPSecEncryption string `json:"ipsec_encryption,omitempty"`
SMSCenter string `json:"sms_center,omitempty"`
PANIEnabled *bool `json:"pani_enabled,omitempty"`
PANICountry string `json:"pani_country,omitempty"`
PANINode string `json:"pani_node,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
DialURIScheme string `json:"dial_uri_scheme,omitempty"`
UserEqPhone *bool `json:"user_eq_phone,omitempty"`
VoiceCodecs []string `json:"voice_codecs,omitempty"`
@@ -131,7 +135,6 @@ type carrierProfileRegisterOptions struct {
ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
SupportedHeader *string `json:"supported_header,omitempty"`
AllowHeader *string `json:"allow_header,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
@@ -322,6 +325,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
country != "AUTO" &&
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
return false
}
@@ -340,7 +344,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
format != IMSContactFormatStandard && format != IMSContactFormatATT {
format != IMSContactFormatStandard && format != IMSContactFormatATT && format != IMSContactFormatGSMA {
return false
}
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
@@ -353,7 +357,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false
}
}
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
for _, value := range []string{rule.IMS.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
if strings.ContainsAny(value, "\r\n") {
return false
}
@@ -479,8 +483,12 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
}{
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
// GID values identify an MVNO/service profile within a host network and
// therefore outrank the host issuer's broad ICCID prefix. Otherwise a
// home-PLMN+ICCID AT&T rule hides RedPocket/Cricket/etc. even when the SIM
// exposes the carrier bundle's exact GID selector.
{name: "gid1", weight: 90, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
{name: "gid2", weight: 85, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
} {
if len(selector.values) == 0 {
continue
@@ -584,8 +592,15 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
base.IMSIPSecEncryption = value
}
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
if rule.IMS.PANIEnabled != nil {
enabled := *rule.IMS.PANIEnabled
base.PANIEnabled = &enabled
}
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
if value := strings.TrimSpace(rule.IMS.UserAgent); value != "" {
base.IMSUserAgent = value
}
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
base.IMSDialURIScheme = value
}
@@ -620,9 +635,6 @@ func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOp
value := strings.TrimSpace(*rule.AllowHeader)
base.AllowHeader = &value
}
if value := strings.TrimSpace(rule.UserAgent); value != "" {
base.UserAgent = value
}
if rule.PPreferredIdentity {
base.PPreferredIdentity = true
}
@@ -719,8 +731,8 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.ICCID) != "" {
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
imsiCountry := countryCodeForMCC(identity.HomeMCC)
iccidCountry := countryCodeForMCC(mcc)
imsiCountry := CountryCodeForMCC(identity.HomeMCC)
iccidCountry := CountryCodeForMCC(mcc)
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
identity.HomeMCC = mcc
identity.HomeMNC = mnc
@@ -733,7 +745,9 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
return identity
}
func countryCodeForMCC(mcc string) string {
// CountryCodeForMCC returns the ISO 3166-1 alpha-2 country code associated
// with an MCC known to the carrier compatibility database.
func CountryCodeForMCC(mcc string) string {
switch strings.TrimSpace(mcc) {
case "515":
return "PH"
+38
View File
@@ -46,6 +46,31 @@ func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) {
}
}
func TestResolveCarrierProfileGiffgaffIMSHeaders(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
})
options := profile.IMSRegisterOptions
if profile.IMSTransport != "tcp" || options.ContactFormat != IMSContactFormatGSMA {
t.Fatalf("giffgaff IMS transport/contact profile = %#v", profile)
}
if profile.IMSUserAgent != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff User-Agent = %q", profile.IMSUserAgent)
}
if options.SupportedHeader != nil || options.AllowHeader != nil {
t.Fatalf("giffgaff REGISTER header overrides = supported=%v allow=%v", options.SupportedHeader, options.AllowHeader)
}
if options.PAccessNetworkInfo != nil {
t.Fatalf("giffgaff unexpectedly defines a carrier PANI override = %v", *options.PAccessNetworkInfo)
}
if profile.PANIEnabled == nil || !*profile.PANIEnabled || profile.PANICountry != "AUTO" {
t.Fatalf("giffgaff PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if len(options.ContactExtraTags) != 2 || options.ContactExtraTags[0] != "+g.3gpp.mid-call" || options.ContactExtraTags[1] != "+g.3gpp.smsip" {
t.Fatalf("giffgaff Contact tags = %#v", options.ContactExtraTags)
}
}
func TestResolveCarrierProfileATT(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
@@ -55,6 +80,16 @@ func TestResolveCarrierProfileATT(t *testing.T) {
}
}
func TestResolveCarrierProfileRedPocketOutranksBroadATTICCID(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310170000000001",
HomeMCC: "310", HomeMNC: "170", SPN: "Red Pocket", GID1: "42FFFF",
})
if profile.ID != "ipcc-redpocket-310170" || profile.MatchSource != "hplmn+gid1" {
t.Fatalf("RedPocket profile = %#v", profile)
}
}
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
if profile.ID != CarrierProfileStandard {
@@ -69,6 +104,9 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
if profile.IMSRegisterOptions.SupportedHeader != nil {
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
}
if profile.PANIEnabled != nil || profile.PANICountry != "" {
t.Fatalf("standard PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if profile.AllowSMSWithoutContactConfirmation {
t.Fatal("standard profile should require SMS contact confirmation")
}
+5
View File
@@ -635,6 +635,11 @@ func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ip
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
}
}
if strings.EqualFold(plistString(signaling.value["CountryOfOriginationFormat"]), "PANI") {
enabled := true
rule.IMS.PANIEnabled = &enabled
rule.IMS.PANICountry = "AUTO"
}
}
}
if useIPSec {
+9 -3
View File
@@ -42,9 +42,12 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
},
"IMSConfig": map[string]any{
"EnableWiFiCallingWithoutEntitlement": true,
"Signaling": map[string]any{"UseIPSec": true},
"Media": map[string]any{"SupportPCMA": false},
"Emergency": map[string]any{"E911OverITechSupported": true},
"Signaling": map[string]any{
"UseIPSec": true,
"CountryOfOriginationFormat": "PANI",
},
"Media": map[string]any{"SupportPCMA": false},
"Emergency": map[string]any{"E911OverITechSupported": true},
},
},
},
@@ -73,6 +76,9 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
if rule.IMS.IPSecEncryption != "aes-cbc" {
t.Fatalf("converted IMS profile = %#v", rule.IMS)
}
if rule.IMS.PANIEnabled == nil || !*rule.IMS.PANIEnabled || rule.IMS.PANICountry != "AUTO" {
t.Fatalf("converted PANI behavior = enabled=%v country=%q", rule.IMS.PANIEnabled, rule.IMS.PANICountry)
}
for _, code := range []string{
"remote_certificate_bypass_ignored",
"disabled_dpd_ignored",
+33 -2
View File
@@ -570,6 +570,14 @@
{
"id": "ipcc-redpocket-310170",
"match_any": [
{
"home_plmns": [
"310170"
],
"gid1_prefixes": [
"42"
]
},
{
"home_plmns": [
"310410"
@@ -585,6 +593,18 @@
"gid1_prefixes": [
"42"
]
},
{
"home_plmns": [
"310170",
"310410",
"310280"
],
"spns": [
"Red Pocket",
"RedPocket",
"Red Pocket Mobile"
]
}
],
"epdg": {
@@ -5766,7 +5786,18 @@
"proposal": "modern"
},
"ims": {
"ipsec_encryption": "aes-cbc"
"transport": "tcp",
"ipsec_encryption": "aes-cbc",
"pani_enabled": true,
"pani_country": "AUTO",
"user_agent": "iOS/18.6.2 iPhone",
"register_options": {
"contact_format": "gsma",
"contact_extra_tags": [
"+g.3gpp.mid-call",
"+g.3gpp.smsip"
]
}
}
},
{
@@ -11783,4 +11814,4 @@
}
],
"version": 1
}
}
+82 -1
View File
@@ -29,6 +29,7 @@ var (
const (
usimAIDPrefix = "A0000000871002"
isimAIDPrefix = "A0000000871004"
efDIRFileID = 0x2f00
efADDecimal = 28589 // 0x6FAD
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
channelCleanupTimeout = 3 * time.Second
@@ -1025,6 +1026,19 @@ func (adapter *EC20Adapter) discoverAKAApplication(
}
}
}
// CUAD is optional and is rejected by a number of EC20 firmware branches.
// In that case do not immediately fall back to the seven-byte registered
// application-provider prefix: cards may expose multiple USIM instances and
// require the complete PIX from EF_DIR to select the provisioned one. Read
// EF_DIR over the standards-based basic channel, which remains available on
// the same firmware that rejects CCHO/CGLA.
if discovered, discoverErr := adapter.discoverBasicApplicationAID(
ctx,
deviceID,
usimAIDPrefix,
); discoverErr == nil {
return discovered, "USIM", nil
}
// AT+CUAD is optional on older EC20 firmware. CCHO still provides a
// standards-based, evidence-bearing probe of the assigned USIM AID.
@@ -1053,6 +1067,64 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication(
return aidPrefix, application, nil
}
func (adapter *EC20Adapter) discoverBasicApplicationAID(
ctx context.Context,
deviceID string,
aidPrefix string,
) (string, error) {
selectFile := func(fileID uint16) error {
apdu := []byte{
0x00, 0xa4, 0x00, 0x04, 0x02,
byte(fileID >> 8), byte(fileID), 0x00,
}
raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false)
if err != nil {
return err
}
_, status, err := splitAPDUStatus(raw)
if err != nil {
return err
}
if status != 0x9000 {
return fmt.Errorf("vocat: EC20 basic-channel SELECT returned %04X", status)
}
return nil
}
if err := selectFile(0x3f00); err != nil {
return "", fmt.Errorf("select EC20 MF for application discovery: %w", err)
}
if err := selectFile(efDIRFileID); err != nil {
return "", fmt.Errorf("select EC20 EF_DIR for application discovery: %w", err)
}
for record := 1; record <= 32; record++ {
raw, err := adapter.transmitBasicAPDU(
ctx,
deviceID,
[]byte{0x00, 0xb2, byte(record), 0x04, 0x00},
false,
)
if err != nil {
return "", fmt.Errorf("read EC20 EF_DIR record %d: %w", record, err)
}
body, status, err := splitAPDUStatus(raw)
if err != nil {
return "", err
}
if status == 0x6a83 || status == 0x9402 {
break
}
if status != 0x9000 {
continue
}
for _, candidate := range collectApplicationAIDs(body) {
if strings.HasPrefix(candidate, aidPrefix) {
return candidate, nil
}
}
}
return "", ErrEC20ApplicationAbsent
}
func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context,
deviceID string,
@@ -1174,8 +1246,17 @@ func (adapter *EC20Adapter) transmitBasicAPDU(
if err != nil {
return nil, err
}
collected = append(collected, body...)
sw1 := byte(status >> 8)
if sw1 == 0x6c {
// The UICC knows the exact response length. Retry the original APDU
// with the advised Le without retaining the procedure response.
if len(current) < 5 {
return nil, errors.New("vocat: EC20 APDU cannot apply corrected response length")
}
current[len(current)-1] = byte(status)
continue
}
collected = append(collected, body...)
if sw1 != 0x61 && sw1 != 0x9f {
collected = append(collected, byte(status>>8), byte(status))
return collected, nil
+53
View File
@@ -263,6 +263,59 @@ func TestEC20AdapterCSIMFallbackSupportsSuccessAndSynchronizationFailure(
}
}
func TestEC20AdapterDiscoversFullUSIMAIDFromEFDIRWhenCUADFails(t *testing.T) {
t.Parallel()
const fullAID = "A0000000871002FFFFFFFF8903020000"
record := "61184F10" + fullAID + "50045553494D"
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
var challenge AKAChallenge
for index := range challenge.RAND {
challenge.RAND[index] = byte(index)
challenge.AUTN[index] = byte(0xf0 + index)
}
authAPDU := buildUSIMAuthenticateAPDU(challenge)
authCommand := fmt.Sprintf(
`AT+CSIM=%d,"%s"`,
len(authAPDU)*2,
strings.ToUpper(hex.EncodeToString(authAPDU)),
)
selectApplication := `AT+CSIM=42,"00A4040410` + fullAID + `"`
transcript := &ec20Transcript{
t: t,
steps: append(
identityTranscriptStepsWithoutEFAD("310280000000001"),
[]ec20TranscriptStep{
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: "AT+CUAD", err: errors.New("+CME ERROR: 13"), final: "+CME ERROR: 13"},
{command: `AT+CSIM=16,"00A40004023F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=16,"00A40004022F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=10,"00B2010400"`, lines: []string{`+CSIM: 4,"6C1A"`}},
{command: `AT+CSIM=10,"00B201041A"`, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s9000"`, len(record)+4, record)}},
{command: `AT+CCHO="` + fullAID + `"`, err: errors.New("unsupported"), final: "ERROR"},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: authCommand, sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encodedResponse), encodedResponse)}},
}...,
),
}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if _, err := adapter.CheckReady(context.Background(), identity); err != nil {
t.Fatalf("CheckReady: %v", err)
}
if _, err := adapter.Authenticate(context.Background(), identity, challenge); err != nil {
t.Fatalf("Authenticate: %v", err)
}
transcript.assertDone()
}
func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse(
t *testing.T,
) {
+5 -1
View File
@@ -36,8 +36,12 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
var systemErr error
for _, targetHost := range hostsToTry {
addresses, err := resolver.LookupIPAddr(ctx, targetHost)
ips, err := resolver.LookupIP(ctx, "ip4", targetHost)
if err == nil {
addresses := make([]net.IPAddr, 0, len(ips))
for _, ip := range ips {
addresses = append(addresses, net.IPAddr{IP: ip})
}
valid := filterValidPublicEPDGAddresses(addresses)
if len(valid) > 0 {
return valid, nil
+3 -1
View File
@@ -393,9 +393,11 @@ func parseInnerIPv6(packet []byte) (innerPacketMetadata, error) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated")
}
payloadLength := int(binary.BigEndian.Uint16(packet[4:6]))
if payloadLength+40 != len(packet) {
declaredLength := payloadLength + 40
if declaredLength > len(packet) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid")
}
packet = packet[:declaredLength]
metadata := innerPacketMetadata{
source: append(net.IP(nil), packet[8:24]...),
destination: append(net.IP(nil), packet[24:40]...),
+20
View File
@@ -318,6 +318,26 @@ func TestParseInnerIPv6ESP(t *testing.T) {
}
}
func TestParseInnerIPv6ESPTrimsTrailingAlignmentBytes(t *testing.T) {
t.Parallel()
packet := make([]byte, 40+20+4)
packet[0] = 0x60
binary.BigEndian.PutUint16(packet[4:6], 20)
packet[6] = 6
packet[7] = 64
copy(packet[8:24], net.ParseIP("2001:db8::1").To16())
copy(packet[24:40], net.ParseIP("2001:db8::2").To16())
binary.BigEndian.PutUint16(packet[40:42], 49686)
binary.BigEndian.PutUint16(packet[42:44], 5060)
metadata, err := parseInnerPacket(packet)
if err != nil {
t.Fatal(err)
}
if metadata.protocol != 6 || metadata.sourcePort != 49686 || metadata.destinationPort != 5060 {
t.Fatalf("metadata = %+v", metadata)
}
}
func mustDefaultESPTunnel(t *testing.T) *espTunnel {
t.Helper()
return mustTestESPTunnel(
+30 -21
View File
@@ -116,7 +116,7 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
"P-Preferred-Service: "+mmtelServiceURN,
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
"Supported: 100rel, timer, replaces",
"Session-Expires: 1800;refresher=uac",
@@ -146,6 +146,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS call started",
"category", "call",
"device_id", session.request.DeviceID,
"direction", "outgoing",
"identity_source", identitySource,
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
@@ -228,6 +230,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
} else {
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode,
"error", safeSIPDiagnostic(ackErr.Error()),
@@ -368,6 +372,14 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
session.callMu.Lock()
session.calls[callID] = call
session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS incoming call received",
"category", "call",
"device_id", session.request.DeviceID,
"caller", call.public.Number,
"call_id", call.public.ID,
)
}
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
calledNumber := identityNumber(request.value("To"))
if calledNumber == "" {
@@ -488,7 +500,7 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d ACK", call.cseq),
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
session.writeMu.Lock()
@@ -566,7 +578,7 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
@@ -697,7 +709,7 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d %s", cseq, method),
"Supported: 100rel, timer",
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
)
if method != "CANCEL" {
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
@@ -759,6 +771,13 @@ func (session *Session) dialogContactHeader() string {
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
return ""
}
if session.imsRegisterOptions().ContactFormat == vowifi.IMSContactFormatGSMA {
contact := "Contact: <sip:" + session.contactAddress() + `>;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
}
return contact
}
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
@@ -799,25 +818,13 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
}
func (session *Session) pAccessNetworkInfo() string {
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
node := strings.TrimSpace(profile.PANINode)
if node == "" {
node = "000000000000"
if session == nil {
return ""
}
value := "IEEE-802.11;i-wlan-node-id=" + node
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
value += ";country=" + country
if session.paniResolved {
return session.pani
}
return value + ";network-provided"
}
func (session *Session) callUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
}
return "vocat/1"
return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger())
}
func callResponseDiagnostic(response *sipResponse) string {
@@ -843,6 +850,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
return
}
session.provider.config.Logger.Info("IMS call response",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode,
"diagnostic", diagnostic,
+1 -1
View File
@@ -229,7 +229,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
"P-Preferred-Identity: <tel:+447700900123>\r\n",
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided\r\n",
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode + "\r\n",
"User-Agent: VoCat Test\r\n",
"Accept: application/sdp\r\n",
} {
+170 -14
View File
@@ -23,6 +23,7 @@ const (
defaultRegistrationExpiry = 3600 * time.Second
defaultTransactionTimeout = 12 * time.Second
maxAuthenticationChallenges = 3
defaultPANIWLANNode = "ffffffffffff"
)
var (
@@ -594,6 +595,8 @@ type Session struct {
callID string
fromTag string
instanceID string
pani string
paniResolved bool
cseq uint32
auth *authenticationState
securityProposal securityProposal
@@ -646,6 +649,11 @@ func newSession(
if err != nil {
return nil, err
}
instanceURI := "urn:uuid:" + instanceID
profile := vowifi.ResolveCarrierProfile(request.Identity)
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
instanceURI = sipInstanceID(request.Identity, instanceID)
}
refreshContext, refreshCancel := context.WithCancel(context.Background())
session := &Session{
provider: provider,
@@ -657,7 +665,9 @@ func newSession(
conn: connection,
callID: callToken + "@" + addressHost(connection.LocalAddr()),
fromTag: fromTag,
instanceID: "urn:uuid:" + instanceID,
instanceID: instanceURI,
pani: resolveSessionPAccessNetworkInfo(request.Identity, provider.config.Logger),
paniResolved: true,
cseq: 1,
refreshContext: refreshContext,
refreshCancel: refreshCancel,
@@ -965,11 +975,7 @@ func (session *Session) buildRegister(
allow = *registerOptions.AllowHeader
}
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
if override := strings.TrimSpace(registerOptions.UserAgent); override != "" &&
(userAgent == "" || userAgent == "vocat/1") {
userAgent = override
}
userAgent := session.imsUserAgent()
lines := []string{
"REGISTER " + requestURI + " SIP/2.0",
@@ -991,19 +997,16 @@ func (session *Session) buildRegister(
}
lines = append(lines, "User-Agent: "+userAgent)
defaultPANI := "IEEE-802.11;i-wlan-node-id=000000000000;network-provided"
pani := defaultPANI
if registerOptions.PAccessNetworkInfo != nil {
pani = *registerOptions.PAccessNetworkInfo
}
if registerOptions.PPreferredIdentity {
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
}
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
lines = append(lines, `P-Visited-Network-ID: "`+value+`"`)
}
if pani != "" {
// PANI describes this UE's access and is stable for the complete IMS
// session. The same UE-provided value is used by REGISTER, MESSAGE,
// RP-ACK and dialog requests; it never claims to be network-provided.
if pani := session.pAccessNetworkInfo(); pani != "" {
lines = append(lines, "P-Access-Network-Info: "+pani)
}
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
@@ -1062,6 +1065,15 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
`%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
base, extra, icsiRef, instanceID,
)
case vowifi.IMSContactFormatGSMA:
extra := ""
for _, tag := range registerOptions.ContactExtraTags {
extra += ";" + tag
}
return fmt.Sprintf(
`<sip:%s>;+g.3gpp.icsi-ref="%s"%s;+sip.instance="<%s>"`,
contactAddress, icsiRef, extra, instanceID,
)
default:
extra := ""
for _, tag := range registerOptions.ContactExtraTags {
@@ -1074,6 +1086,144 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
}
}
// sipInstanceID uses the standardized GSMA device-instance URI when a valid
// modem identity is available and keeps the generated UUID as the fallback.
func sipInstanceID(identity vowifi.SIMIdentity, fallback string) string {
imei := strings.TrimSpace(identity.IMEI)
if len(imei) == 15 {
valid := true
for _, digit := range imei {
if digit < '0' || digit > '9' {
valid = false
break
}
}
if valid {
return "urn:gsma:imei:" + imei + "-0"
}
}
return "urn:uuid:" + strings.TrimSpace(fallback)
}
func (session *Session) imsRegisterOptions() vowifi.IMSRegisterOptions {
if session == nil {
return vowifi.IMSRegisterOptions{}
}
return vowifi.ResolveCarrierProfile(session.request.Identity).IMSRegisterOptions
}
func (session *Session) imsUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
if value != "vocat/1" {
return value
}
}
}
if session != nil {
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
if value := strings.TrimSpace(profile.IMSUserAgent); value != "" {
return value
}
}
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
}
return "vocat/1"
}
func (session *Session) imsLogger() *slog.Logger {
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
return session.provider.config.Logger
}
return slog.Default()
}
// resolveSessionPAccessNetworkInfo freezes the selected value when the IMS
// session is created. This prevents a carrier-profile reload from changing
// access identity between REGISTER, SMS MESSAGE and its RP-ACK.
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, logger *slog.Logger) string {
if logger == nil {
logger = slog.Default()
}
profile := vowifi.ResolveCarrierProfile(identity)
if profile.PANIEnabled != nil && !*profile.PANIEnabled {
return ""
}
if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil {
return appendPaniCountry(ueProvidedPANI(*configured), identity, profile, logger)
}
node := strings.ToLower(strings.TrimSpace(profile.PANINode))
if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 {
node = defaultPANIWLANNode
}
if node == "" {
return ""
}
value := "IEEE-802.11;i-wlan-node-id=" + node
return appendPaniCountry(value, identity, profile, logger)
}
func appendPaniCountry(value string, identity vowifi.SIMIdentity, profile vowifi.CarrierProfile, logger *slog.Logger) string {
value = strings.TrimSpace(value)
if value == "" {
return value
}
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.
// RFC 7315 reserves that marker for a trusted proxy; a UE must not assert it.
func ueProvidedPANI(value string) string {
parts := strings.Split(strings.TrimSpace(value), ";")
filtered := parts[:0]
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || strings.EqualFold(part, "network-provided") {
continue
}
filtered = append(filtered, part)
}
return strings.Join(filtered, ";")
}
func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) {
if err := ctx.Err(); err != nil {
return nil, err
@@ -1209,6 +1359,7 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
serviceRoutes := splitHeaderValues(response.values("Service-Route"))
registeredContact := ""
smsConfirmed := false
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
instanceLower := strings.ToLower(session.instanceID)
contactURILower := strings.ToLower(fmt.Sprintf(
"sip:%s@%s;transport=%s",
@@ -1216,10 +1367,15 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
session.contactAddress(),
session.transport,
))
contactAddressLower := ""
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
contactAddressLower = strings.ToLower("sip:" + session.contactAddress())
}
for _, contact := range contacts {
lower := strings.ToLower(contact)
matchesThisSession := strings.Contains(lower, instanceLower) ||
strings.Contains(lower, contactURILower)
strings.Contains(lower, contactURILower) ||
(contactAddressLower != "" && strings.Contains(lower, contactAddressLower))
if matchesThisSession {
registeredContact = contact
smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip")
+170 -2
View File
@@ -3,6 +3,7 @@ package ims
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -361,6 +362,7 @@ func TestRefreshFailureRevokesRegistrationEvidence(t *testing.T) {
func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) error {
var callID string
var pani string
for step := 0; step < 4; step++ {
packet := make([]byte, 65535)
count, remote, err := listener.ReadFromUDP(packet)
@@ -386,8 +388,14 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
)
}
}
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
currentPANI := headers["p-access-network-info"]
if err := validateTestPANI(currentPANI); err != nil {
return fmt.Errorf("REGISTER PANI: %w", err)
}
if step == 0 {
pani = currentPANI
} else if currentPANI != pani {
return fmt.Errorf("REGISTER PANI changed from %q to %q", pani, currentPANI)
}
if !strings.Contains(headers["allow"], "MESSAGE") ||
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
@@ -497,6 +505,166 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
return nil
}
func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
defaultPANI := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
if err := validateTestPANI(defaultPANI); err != nil {
t.Fatal(err)
}
if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" {
t.Fatalf("ueProvidedPANI() = %q", got)
}
if got := ueProvidedPANI("network-provided"); got != "" {
t.Fatalf("marker-only PANI = %q, want empty", got)
}
if got := (&Session{pani: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, paniResolved: true}).pAccessNetworkInfo(); got != "IEEE-802.11;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("session PANI = %q, want default WLAN node", got)
}
}
func TestPAccessNetworkInfoUsesDefaultNodeAndConditionalCountry(t *testing.T) {
cases := []struct {
name string
identity vowifi.SIMIdentity
want string
}{
{
name: "standard without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "giffgaff with IPCC PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
want: "IEEE-802.11;country=GB;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "AT&T without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "VOXI without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
got := resolveSessionPAccessNetworkInfo(test.identity, slog.New(slog.NewTextHandler(io.Discard, nil)))
if got != test.want {
t.Fatalf("PANI = %q, want %q", got, test.want)
}
})
}
}
func TestAppendPaniCountryModes(t *testing.T) {
base := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
identity := vowifi.SIMIdentity{HomeMCC: "234"}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{}, slog.Default()); got != base {
t.Fatalf("empty PANI country = %q, want %q", got, base)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "GB"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("fixed PANI country = %q", got)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "AUTO"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("automatic PANI country = %q", got)
}
var logs strings.Builder
logger := slog.New(slog.NewTextHandler(&logs, nil))
got := appendPaniCountry(base, vowifi.SIMIdentity{}, vowifi.CarrierProfile{ID: "test-auto", PANICountry: "AUTO"}, logger)
if got != base || !strings.Contains(logs.String(), "IMS PANI country code could not be derived") {
t.Fatalf("failed automatic PANI country = %q, logs = %q", got, logs.String())
}
}
func TestIMSProfileUserAgentUsesUnifiedHeaderValue(t *testing.T) {
giffgaff := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
}}}
if got := giffgaff.imsUserAgent(); got != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff IMS User-Agent = %q", got)
}
if options := giffgaff.imsRegisterOptions(); options.AllowHeader != nil || options.SupportedHeader != nil {
t.Fatalf("giffgaff REGISTER capability overrides leaked from business headers: %#v", options)
}
standard := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
}}}
if got := standard.imsUserAgent(); got != "vocat/1" {
t.Fatalf("standard IMS User-Agent fallback = %q", got)
}
}
func TestSipInstanceIDUsesGSMAFormWhenIMEIIsAvailable(t *testing.T) {
identity := vowifi.SIMIdentity{IMEI: "353024112557010"}
if got := sipInstanceID(identity, "00000000-0000-4000-8000-000000000001"); got != "urn:gsma:imei:353024112557010-0" {
t.Fatalf("sipInstanceID() = %q", got)
}
if got := sipInstanceID(vowifi.SIMIdentity{IMEI: "not-an-imei"}, "00000000-0000-4000-8000-000000000001"); got != "urn:uuid:00000000-0000-4000-8000-000000000001" {
t.Fatalf("sipInstanceID() fallback = %q", got)
}
}
func TestGSMAContactFormatUsesAddressAndDeviceInstance(t *testing.T) {
session := &Session{
identity: identitySet{user: "234105776448519"},
transport: "tcp",
instanceID: "urn:gsma:imei:353024112557010-0",
}
got := session.buildContact("[2001:db8::1]:49686", vowifi.IMSRegisterOptions{
ContactFormat: vowifi.IMSContactFormatGSMA,
ContactExtraTags: []string{"+g.3gpp.mid-call", "+g.3gpp.smsip"},
})
want := `<sip:[2001:db8::1]:49686>;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel";+g.3gpp.mid-call;+g.3gpp.smsip;+sip.instance="<urn:gsma:imei:353024112557010-0>"`
if got != want {
t.Fatalf("GSMA Contact = %q, want %q", got, want)
}
}
func validateTestPANI(value string) error {
const accessType = "IEEE-802.11"
if !strings.HasPrefix(value, accessType+";") {
return fmt.Errorf("value %q does not start with %q", value, accessType+";")
}
if strings.Contains(strings.ToLower(value), "network-provided") {
return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value)
}
var nodeValue, country string
for _, parameter := range strings.Split(strings.TrimPrefix(value, accessType+";"), ";") {
key, parameterValue, ok := strings.Cut(parameter, "=")
if !ok {
continue
}
switch strings.ToLower(strings.TrimSpace(key)) {
case "i-wlan-node-id":
nodeValue = strings.TrimSpace(parameterValue)
case "country":
country = strings.TrimSpace(parameterValue)
}
}
if nodeValue == "" {
return fmt.Errorf("i-wlan-node-id is missing: %q", value)
}
node, err := hex.DecodeString(nodeValue)
if err != nil || len(node) != 6 {
return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value)
}
if strings.EqualFold(nodeValue, defaultPANIWLANNode) {
if country != "" && len(country) != 2 {
return fmt.Errorf("country must be an ISO alpha-2 code: %q", value)
}
return nil
}
if node[0]&0x03 != 0x02 {
return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value)
}
return nil
}
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
var callID string
for step := 0; step < 3; step++ {
+8 -1
View File
@@ -56,6 +56,7 @@ type ReceivedSMS struct {
RawTPDU string
DecodeError string
}
// ReceivedSMSStatus is network delivery evidence for one submitted SMS part.
type ReceivedSMSStatus struct {
DeviceID string
@@ -910,7 +911,7 @@ func (session *Session) logInboundSMS(level slog.Level, message string, request
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger
}
base := []any{"device_id", session.request.DeviceID}
base := []any{"category", "sms", "subsystem", "ims", "device_id", session.request.DeviceID}
if request != nil {
base = append(base,
"call_id", strings.TrimSpace(request.value("Call-ID")),
@@ -1090,6 +1091,8 @@ func (session *Session) logOutboundSMS(level slog.Level, message string, attribu
}
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
base := []any{
"category", "sms",
"subsystem", "ims",
"device_id", session.request.DeviceID,
"home_plmn", plmn,
"transport", session.transport,
@@ -1165,12 +1168,16 @@ func (session *Session) sendSIPMessageWith(
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
"P-Preferred-Identity: <"+session.identity.public+">",
)
if pani := session.pAccessNetworkInfo(); pani != "" {
lines = append(lines, "P-Access-Network-Info: "+pani)
}
if acceptContactTag != "" {
lines = append(lines, "Accept-Contact: *;+g.3gpp."+acceptContactTag)
}
lines = append(lines,
"Request-Disposition: no-fork",
"Allow: MESSAGE",
"User-Agent: "+session.imsUserAgent(),
)
if inReplyTo != "" {
lines = append(lines, "In-Reply-To: "+inReplyTo)
+29
View File
@@ -352,6 +352,10 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
if err != nil {
return err
}
registerPANI := headers["p-access-network-info"]
if err := validateTestPANI(registerPANI); err != nil {
return fmt.Errorf("initial REGISTER PANI: %w", err)
}
callID := headers["call-id"]
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", callID, headers["cseq"], []string{
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
@@ -366,6 +370,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
if err != nil {
return err
}
if headers["p-access-network-info"] != registerPANI {
return errors.New("authenticated REGISTER changed PANI")
}
if _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], []string{
"Contact: " + headers["contact"] + ";expires=600",
}), remote); err != nil {
@@ -433,6 +440,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
len(report.Request.Body) != 2 || report.Request.Body[0] != 0x02 || report.Request.Body[1] != 0x2a {
return fmt.Errorf("unexpected delivery report %#v", report.Request)
}
if report.Request.value("P-Access-Network-Info") != registerPANI {
return errors.New("inbound SMS RP-ACK did not reuse REGISTER PANI")
}
if _, err = listener.WriteToUDP(testResponse(200, "OK", report.Request.value("Call-ID"), report.Request.value("CSeq"), nil), remote); err != nil {
return err
}
@@ -449,6 +459,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
if headers["expires"] != "0" {
return errors.New("expected deregistration")
}
if headers["p-access-network-info"] != registerPANI {
return errors.New("deregistration changed PANI")
}
_, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], nil), remote)
return err
}
@@ -463,6 +476,10 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
if err != nil {
return err
}
registerPANI := headers["p-access-network-info"]
if err := validateTestPANI(registerPANI); err != nil {
return fmt.Errorf("initial REGISTER PANI: %w", err)
}
registerCallID := headers["call-id"]
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", registerCallID, headers["cseq"], []string{
`WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`,
@@ -477,6 +494,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
if err != nil {
return err
}
if headers["p-access-network-info"] != registerPANI {
return errors.New("authenticated REGISTER changed PANI")
}
if _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], []string{
"Contact: " + headers["contact"] + ";expires=600",
}), remote); err != nil {
@@ -508,6 +528,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
message.Request.value("Allow") != "MESSAGE" {
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
}
if message.Request.value("P-Access-Network-Info") != registerPANI {
return errors.New("outbound SMS MESSAGE did not reuse REGISTER PANI")
}
rpdu, err := parseRPDU(message.Request.Body)
if err != nil || rpdu.messageType != 0 || len(rpdu.tpdu) != 0 {
// parseRPDU intentionally decodes only network-to-MS RP-DATA; inspect
@@ -573,6 +596,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
len(statusACK.Request.Body) != 2 || statusACK.Request.Body[0] != 0x02 || statusACK.Request.Body[1] != 0x2b {
return fmt.Errorf("unexpected status RP-ACK %#v (%v)", statusACK.Request, err)
}
if statusACK.Request.value("P-Access-Network-Info") != registerPANI {
return errors.New("status-report RP-ACK did not reuse REGISTER PANI")
}
if _, err = listener.WriteToUDP(testResponse(200, "OK", statusACK.Request.value("Call-ID"), statusACK.Request.value("CSeq"), nil), remote); err != nil {
return err
}
@@ -589,6 +615,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
if headers["expires"] != "0" {
return errors.New("expected deregistration")
}
if headers["p-access-network-info"] != registerPANI {
return errors.New("deregistration changed PANI")
}
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
return err
}
+1 -1
View File
@@ -144,7 +144,7 @@ func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context,
}
raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge))
if err != nil {
return AKAResult{}, ErrEC20AKACommand
return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20AKACommand, err)
}
return parseUSIMAuthenticateResponse(raw)
}
+10 -6
View File
@@ -11,12 +11,13 @@ import { message } from "../ui/message";
type RetentionMode = LoggingSettings["mode"];
// 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。
export function LogRetentionCard() {
export function LogRetentionCard({ refreshKey = 0 }: { refreshKey?: number }) {
const { t } = useI18n();
const [mode, setMode] = useState<RetentionMode>("unlimited");
const [count, setCount] = useState(10000);
const [days, setDays] = useState(30);
const [storedLogs, setStoredLogs] = useState(0);
const [maxLogs, setMaxLogs] = useState(10000);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -25,6 +26,7 @@ export function LogRetentionCard() {
setCount(data.count);
setDays(data.days);
setStoredLogs(data.storedLogs);
setMaxLogs(data.maxLogs || 10000);
}, []);
useEffect(() => {
@@ -41,14 +43,14 @@ export function LogRetentionCard() {
return () => {
cancelled = true;
};
}, [apply]);
}, [apply, refreshKey]);
const save = useCallback(async () => {
setSaving(true);
try {
const data = await updateLoggingSettings({
mode,
count: Math.max(1, Math.trunc(count) || 1),
count: Math.min(maxLogs, Math.max(1, Math.trunc(count) || 1)),
days: Math.max(1, Math.trunc(days) || 1),
});
apply(data);
@@ -58,7 +60,7 @@ export function LogRetentionCard() {
} finally {
setSaving(false);
}
}, [mode, count, days, apply]);
}, [mode, count, days, maxLogs, apply]);
const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseInt(event.target.value, 10);
@@ -78,7 +80,7 @@ export function LogRetentionCard() {
className="w-32"
disabled={loading}
options={[
{ value: "unlimited", label: t("不限制") },
{ value: "unlimited", label: t("最多 10000 条") },
{ value: "count", label: t("按条数") },
{ value: "days", label: t("按天数") },
]}
@@ -88,6 +90,7 @@ export function LogRetentionCard() {
<Input
type="number"
min={1}
max={maxLogs}
value={count === 0 ? "" : count}
onChange={onNumber(setCount)}
disabled={loading}
@@ -110,8 +113,9 @@ export function LogRetentionCard() {
</label>
) : null}
<span className="text-sm text-gray-400">
{t("当前已存储")} {storedLogs} {t("条")}
{t("当前已存储")} {storedLogs} / {maxLogs} {t("条")}
</span>
<span className="text-xs text-gray-400">{t("达到上限后自动删除最旧日志")}</span>
<div className="flex-1" />
<Button
size="small"
+16
View File
@@ -498,6 +498,22 @@ export const EN_DICT: Record<string, string> = {
"连接中断,正在尝试重连…": "Connection lost, reconnecting…",
: "Logs exported",
"查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search",
: "Device & Service Logs",
"记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码":
"Hardware, network registration, WiFi Calling, SMS, calls, and user operations. Sensitive identities are automatically redacted.",
: "Category",
: "All Services",
: "Hardware & Modem",
: "Network Registration",
: "Calls",
: "User Operations",
: "System",
"最多 10000 条": "Up to 10,000",
: "The oldest logs are automatically removed at the limit",
"此操作会永久删除服务端保存的全部日志,无法恢复。": "This permanently deletes all logs stored on the server and cannot be undone.",
"确认清空日志?": "Clear all logs?",
: "Logs cleared",
: "Failed to clear logs",
: "Resume",
: "Pause",
: "Connected",
+113 -26
View File
@@ -16,11 +16,13 @@ import { Switch } from "../components/ui/Switch";
import { Select } from "../components/ui/Select";
import { Input } from "../components/ui/Input";
import { message } from "../components/ui/message";
import { confirmDialog } from "../components/ui/MessageBox";
import { LogRetentionCard } from "../components/logs/LogRetentionCard";
const MAX_LOGS = 1000;
type Level = "all" | "debug" | "info" | "warn" | "error";
type Category = "all" | "hardware" | "network" | "vowifi" | "sms" | "call" | "operation" | "system";
const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "all", label: "全部" },
@@ -30,6 +32,17 @@ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
{ value: "error", label: "ERROR" },
];
const CATEGORY_OPTIONS: { value: Category; label: string }[] = [
{ value: "all", label: "全部业务" },
{ value: "hardware", label: "硬件与模块" },
{ value: "network", label: "驻网" },
{ value: "vowifi", label: "WiFi Calling" },
{ value: "sms", label: "短信" },
{ value: "call", label: "通话" },
{ value: "operation", label: "用户操作" },
{ value: "system", label: "系统错误" },
];
function levelColor(level: string): string {
switch (level.toLowerCase()) {
case "debug":
@@ -52,6 +65,42 @@ function fieldsText(fields: LogEntry["fields"]): string {
return typeof fields === "string" ? fields : JSON.stringify(fields);
}
function logFields(entry: LogEntry): Record<string, unknown> {
return entry.fields && typeof entry.fields === "object" ? entry.fields : {};
}
function logCategory(entry: LogEntry): Exclude<Category, "all"> {
const explicit = String(logFields(entry).category ?? "").toLowerCase();
if (CATEGORY_OPTIONS.some((item) => item.value === explicit && item.value !== "all")) {
return explicit as Exclude<Category, "all">;
}
const text = `${entry.message} ${fieldsText(entry.fields)}`.toLowerCase();
if (/\bsms\b|短信|tpdu|rp-data|rpdu/.test(text)) return "sms";
if (/incoming call|\bcall\b|invite|来电|通话/.test(text)) return "call";
if (/vowifi|wi-?fi calling|\bims\b|\bike\b|epdg|ipsec/.test(text)) return "vowifi";
if (/registration|operator|network|驻网|注册网络/.test(text)) return "network";
if (/device|modem|hardware|sim|uicc|esim|qmi|串口|模块|设备/.test(text)) return "hardware";
if (/operation|audit|setting|操作/.test(text)) return "operation";
return "system";
}
function categoryColor(category: Exclude<Category, "all">): string {
switch (category) {
case "hardware": return "bg-cyan-500/15 text-cyan-300";
case "network": return "bg-emerald-500/15 text-emerald-300";
case "vowifi": return "bg-sky-500/15 text-sky-300";
case "sms": return "bg-violet-500/15 text-violet-300";
case "call": return "bg-pink-500/15 text-pink-300";
case "operation": return "bg-amber-500/15 text-amber-300";
default: return "bg-gray-500/20 text-gray-300";
}
}
function isHTTPAccessLog(entry: LogEntry): boolean {
return entry.message.trim().toLowerCase() === "http request" ||
String(logFields(entry).category ?? "").toLowerCase() === "http_access";
}
// Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp.
function displayTime(time: string): string {
try {
@@ -71,8 +120,11 @@ export default function LogsPage() {
const [paused, setPaused] = useState(false);
const [autoTail, setAutoTail] = useState(true);
const [level, setLevel] = useState<Level>("all");
const [category, setCategory] = useState<Category>("all");
const [search, setSearch] = useState("");
const [connError, setConnError] = useState("");
const [clearing, setClearing] = useState(false);
const [retentionRefreshKey, setRetentionRefreshKey] = useState(0);
const esRef = useRef<EventSource | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
@@ -80,6 +132,7 @@ export default function LogsPage() {
const levelRef = useRef<Level>("all");
const appendLog = useCallback((entry: LogEntry) => {
if (isHTTPAccessLog(entry)) return;
setLogs((prev) => {
const next = [...prev, entry];
return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
@@ -118,7 +171,7 @@ export default function LogsPage() {
try {
const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500");
const list = Array.isArray(res) ? res : (res?.logs ?? []);
setLogs(list.slice(-MAX_LOGS));
setLogs(list.filter((entry) => !isHTTPAccessLog(entry)).slice(-MAX_LOGS));
} catch {
/* 历史回填失败不阻塞实时流 */
} finally {
@@ -164,13 +217,34 @@ export default function LogsPage() {
}
}, [connect]);
const clearLogs = useCallback(() => setLogs([]), []);
const clearLogs = useCallback(async () => {
const confirmed = await confirmDialog(
t("此操作会永久删除服务端保存的全部日志,无法恢复。"),
t("确认清空日志?"),
{ type: "warning", confirmText: t("清空"), cancelText: t("取消") },
);
if (!confirmed) return;
setClearing(true);
try {
await api<{ cleared: boolean; deleted: number }>("/logs/history", { method: "DELETE" });
setLogs([]);
setRetentionRefreshKey((value) => value + 1);
message.success(t("日志已清空"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("清空日志失败"));
} finally {
setClearing(false);
}
}, [t]);
const filtered = useMemo(() => {
let list = logs;
if (level !== "all") {
list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase());
}
if (category !== "all") {
list = list.filter((entry) => logCategory(entry) === category);
}
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
@@ -181,14 +255,14 @@ export default function LogsPage() {
);
}
return list;
}, [logs, level, search]);
}, [logs, level, category, search]);
const exportLogs = useCallback(() => {
const text = filtered
.map((v) => {
const time = new Date(v.time).toLocaleString();
const fields = v.fields ? ` ${fieldsText(v.fields)}` : "";
return `[${time}] ${v.level.toUpperCase().padEnd(5)} ${v.caller ?? ""} ${v.message}${fields}`;
return `[${time}] ${v.level.toUpperCase().padEnd(5)} [${logCategory(v)}] ${v.caller ?? ""} ${v.message}${fields}`;
})
.join("\n");
const blob = new Blob([text], { type: "text/plain" });
@@ -204,8 +278,8 @@ export default function LogsPage() {
return (
<div className="max-w-7xl mx-auto">
<PageHeader
title={t("实时日志")}
subtitle={t("查看系统运行日志,支持过滤和搜索")}
title={t("设备与业务日志")}
subtitle={t("记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码")}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -216,7 +290,7 @@ export default function LogsPage() {
>
{paused ? t("继续") : t("暂停")}
</Button>
<Button onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
<Button loading={clearing} onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
{t("清空")}
</Button>
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}>
@@ -255,6 +329,13 @@ export default function LogsPage() {
className="w-full sm:w-40"
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
/>
<Select
value={category}
onChange={(v) => setCategory(v as Category)}
placeholder={t("业务分类")}
className="w-full sm:w-44"
options={CATEGORY_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -279,7 +360,7 @@ export default function LogsPage() {
</div>
</div>
<LogRetentionCard />
<LogRetentionCard refreshKey={retentionRefreshKey} />
<div className="ui-card overflow-hidden">
<div
@@ -291,24 +372,30 @@ export default function LogsPage() {
{loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")}
</div>
) : null}
{filtered.map((entry, i) => (
<div key={i} className="py-0.5 hover:bg-white/5 px-2 -mx-2 rounded whitespace-nowrap">
<span className="text-gray-500">[{displayTime(entry.time)}]</span>
<span className={cx("font-bold ml-1.5", levelColor(entry.level))}>
{entry.level.toUpperCase()}
</span>
<span
className="text-indigo-400 inline-block max-w-48 truncate align-bottom ml-1.5"
title={entry.caller ?? ""}
>
{entry.caller ?? ""}
</span>
<span className="text-gray-100 ml-1.5">{entry.message}</span>
{entry.fields ? (
<span className="text-amber-300/70 ml-1.5">{fieldsText(entry.fields)}</span>
) : null}
</div>
))}
{filtered.map((entry, i) => {
const entryCategory = logCategory(entry);
const fields = logFields(entry);
const hasRawError = fields.raw_error !== undefined || fields.error !== undefined || fields.raw_response !== undefined;
return (
<div key={`${entry.time}-${i}`} className="border-b border-white/5 px-2 py-2 -mx-2 last:border-0 hover:bg-white/5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-gray-500">[{displayTime(entry.time)}]</span>
<span className={cx("font-bold", levelColor(entry.level))}>{entry.level.toUpperCase()}</span>
<span className={cx("rounded px-1.5 py-0.5 text-[11px]", categoryColor(entryCategory))}>
{t(CATEGORY_OPTIONS.find((item) => item.value === entryCategory)?.label ?? "系统错误")}
</span>
{entry.caller ? <span className="max-w-48 truncate text-indigo-400" title={entry.caller}>{entry.caller}</span> : null}
<span className="break-words text-gray-100">{entry.message}</span>
</div>
{entry.fields ? (
<pre className={cx(
"mt-1 whitespace-pre-wrap break-all pl-2 text-xs leading-5",
hasRawError ? "border-l-2 border-red-500/60 text-red-200" : "text-amber-300/70",
)}>{typeof entry.fields === "string" ? entry.fields : JSON.stringify(entry.fields, null, 2)}</pre>
) : null}
</div>
);
})}
</div>
</div>
</div>
+2 -1
View File
@@ -432,12 +432,13 @@ export interface SecuritySettings {
clientAllowed: boolean;
}
// 运行日志保留策略:默认不限制,可按条数或天数限制。
// 运行日志保留策略:全局硬上限 10000 条,可配置更严格的条数或天数限制。
export interface LoggingSettings {
mode: "unlimited" | "count" | "days";
count: number;
days: number;
storedLogs: number;
maxLogs: number;
}
export interface SystemInfo {