Compare commits

..
Author SHA1 Message Date
MengMengCode 04b09e0d97 test(ims): exercise disabled PANI resolution 2026-08-21 23:15:10 +08:00
MengMengCode b12ea27123 fix(ims): omit empty PANI call headers 2026-08-21 23:10:04 +08:00
ihipopandGitHub 54288e5657 fix(ims): align VoWiFi SIP profile behavior (#75) 2026-08-21 22:48:55 +08:00
76af0784e1 fix: stabilize OpenStick 410 VoWiFi startup (#74)
* fix: stabilize OpenStick 410 VoWiFi startup

* fix: recover OpenStick 410 eUICC channel allocation

---------

Co-authored-by: MengMengCode <[email protected]>
2026-08-21 19:26:37 +08:00
06ea65558c fix: ignore non-voice CLCC records in call monitor (#71)
Co-authored-by: geekouc <[email protected]>
2026-08-21 15:44:00 +08:00
MengMengCode d26937f9eb Fix something 2026-08-21 12:25:16 +08:00
MengMengCode 688e1e8311 feat(logging): implement log retention policy with hard limit and exclusion filters
- Added MaxLogEvents constant to enforce a hard limit on stored log events.
- Updated AppendLogEvent to discard older logs when the limit is exceeded.
- Enhanced ListLogEvents to support filtering by log level and excluding specific messages.
- Introduced ClearLogEvents method to permanently remove logs and prevent re-queuing of cleared entries.
- Modified LogRetentionCard component to reflect the new log retention settings and limits.
- Added logging categories for better organization and filtering in the UI.
- Implemented sanitization for sensitive information in logs.
- Added tests for log event limits and clearing functionality.
2026-08-21 01:01:41 +08:00
MengMengCode f697c418a5 FIX #68 #28 2026-08-20 23:37:40 +08:00
MengMengCode 8d06231494 #28 2026-08-20 21:12:34 +08:00
MengMengCode ee22576124 feat: add OnlineRateCard component to display 14-day uptime history with localization support 2026-08-20 16:24:57 +08:00
MengMengCode 4f3f37ba7c feat: implement SMS management features including frontend UI and backend API handlers 2026-08-20 15:35:10 +08:00
MengMengCode 53345d2915 Fix something 2026-08-20 15:13:19 +08:00
MengMengCode 0cba13634a feat: implement carrier profile resolution system and initial profile database for VoWiFi connectivity 2026-08-20 14:36:06 +08:00
MengMengCode b8df7f43f8 feat: implement IKE session relay and transport layer for ePDG communication 2026-08-20 13:14:45 +08:00
MengMengCode 497cd24c8d feat: implement device management and eSIM support services 2026-08-20 12:49:14 +08:00
74 changed files with 3801 additions and 687 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=
+31 -30
View File
@@ -330,6 +330,18 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
// operation self-healing without disturbing an active AKA exchange.
continue
}
if attempt == 1 && isTransientEuiccCME(err) {
// When SIM hot-swap occurs or the modem baseband APDU channel is stuck (+CME ERROR: 0),
// perform a soft SIM subsystem reset (AT+CFUN=0 -> AT+CFUN=1/4) to re-initialize
// card interface voltage and ATR without restarting the whole hardware module.
_ = manager.softResetForProfileSwitch(ctx, id)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(600 * time.Millisecond):
}
continue
}
if !isTransientEuiccCME(err) {
return nil, err
}
@@ -427,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)
@@ -1071,15 +1086,14 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
manager.esimCacheMu.Unlock()
}
// recoverAfterProfileSwitch owns the post-commit reset independently of the
// initiating HTTP request. EC20 commonly drops the AT port while processing
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
// recoverAfterProfileSwitch owns the post-commit SIM reset independently of the
// initiating HTTP request.
func (manager *Manager) recoverAfterProfileSwitch(id string) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
cancelReset()
if err == nil {
time.Sleep(1500 * time.Millisecond)
time.Sleep(1 * time.Second)
}
// Native WWAN identity and profile verification are both QMI-backed.
// Do not enter the AT refresh path: OpenStick firmware can accept the
@@ -1088,52 +1102,39 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
}
cancelReset()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.commandTimeout*2)
_ = manager.softResetForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id)
}
// refreshAfterProfileSwitch repopulates the device snapshot in the background
// after an eSIM profile switch + modem reboot. /overview only serves the cached
// snapshot, and nothing else live-reads post-switch, so without this the card
// stays on "--" forever. The EC20 takes ~10-15s to come back from AT+CFUN=1,1,
// so we delay first, then retry with backoff. Transport errors during the
// reboot window are fine — Fix 1 discards the poisoned client and reopens on
// the next attempt. All errors are swallowed: this is best-effort self-healing
// and setResult already records the last failure for the UI.
// after an eSIM profile switch.
func (manager *Manager) refreshAfterProfileSwitch(id string) {
if manager.isPCSCDevice(id) {
time.Sleep(750 * time.Millisecond)
for attempt := 0; attempt < 10; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
time.Sleep(500 * time.Millisecond)
for attempt := 0; attempt < 5; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
_, _ = manager.Discover(ctx)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
}
time.Sleep(time.Second)
time.Sleep(500 * time.Millisecond)
}
return
}
const (
settle = 8 * time.Second
interval = 4 * time.Second
attempts = 6
settle = 1 * time.Second
interval = 1 * time.Second
attempts = 5
)
time.Sleep(settle)
for attempt := 0; attempt < attempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, flightErr := manager.SetFlight(ctx, id, true)
var err error
if flightErr == nil {
_, err = manager.Refresh(ctx, id)
} else {
err = flightErr
}
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
@@ -1233,7 +1234,7 @@ func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
// is finalized by REFRESH/reset. The UI must not report success until the modem
// is actually exposing the requested ICCID.
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 1*time.Second)
}
func (manager *Manager) verifySwitchedICCIDAttempts(
+4 -4
View File
@@ -30,9 +30,9 @@ func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address
}
func TestParsePendingNotifications(t *testing.T) {
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944470000000000001")
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "8944100000000000001")
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
@@ -44,11 +44,11 @@ func TestParsePendingNotifications(t *testing.T) {
}
// Results are grouped by receiver, then sorted by sequence number.
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
got.Address != "delete.example.com" || got.ICCID != "8944100000000000001" || !bytes.Equal(got.raw, deleted) {
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
}
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
got.Address != "install.example.com" || got.ICCID != "8944470000000000001" || !bytes.Equal(got.raw, install) {
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
}
+18 -18
View File
@@ -55,9 +55,9 @@ func esimTestProfile(t *testing.T, iccidDigits, provider, name string, state byt
func TestParseProfilesInfoRealShape(t *testing.T) {
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
body := tlv([]byte{0xA0},
esimTestProfile(t, "89441000400128014257", "Vodafone UK", "Vodafone UK eSIM", 0x00),
esimTestProfile(t, "89441000430011604140", "Vodafone UK", "Vodafone UK eSIM", 0x01),
esimTestProfile(t, "89852351225001058508", "Webbing", "WEBBING", 0x00),
esimTestProfile(t, "8944100000000000001", "Vodafone UK", "Vodafone UK eSIM", 0x00),
esimTestProfile(t, "8944100000000000002", "Vodafone UK", "Vodafone UK eSIM", 0x01),
esimTestProfile(t, "8985200000000000001", "Webbing", "WEBBING", 0x00),
)
payload := tlv([]byte{0xBF, 0x2D}, body)
@@ -65,10 +65,10 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
if len(profiles) != 3 {
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
}
if profiles[0].ICCID != "89441000400128014257" || profiles[0].State != 0 {
if profiles[0].ICCID != "8944100000000000001" || profiles[0].State != 0 {
t.Fatalf("profile[0] = %#v", profiles[0])
}
if profiles[1].ICCID != "89441000430011604140" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
if profiles[1].ICCID != "8944100000000000002" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
t.Fatalf("profile[1] = %#v", profiles[1])
}
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
@@ -82,8 +82,8 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
}
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
real := esimTestProfile(t, "89441000400316048687", "Vodafone UK", "Vodafone UK eSIM", 0x01)
duplicate := esimTestProfile(t, "89441000400316048687", "Duplicate", "Duplicate", 0x00)
real := esimTestProfile(t, "8944100000000000003", "Vodafone UK", "Vodafone UK eSIM", 0x01)
duplicate := esimTestProfile(t, "8944100000000000003", "Duplicate", "Duplicate", 0x00)
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
empty := tlv([]byte{0xE3})
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
@@ -92,13 +92,13 @@ func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
if len(profiles) != 1 {
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
}
if profiles[0].ICCID != "89441000400316048687" || profiles[0].Name != "Vodafone UK eSIM" {
if profiles[0].ICCID != "8944100000000000003" || profiles[0].Name != "Vodafone UK eSIM" {
t.Fatalf("profile = %#v", profiles[0])
}
}
func TestICCIDRoundTrip(t *testing.T) {
for _, digits := range []string{"89441000400128014257", "8985235122500105850", "1"} {
for _, digits := range []string{"8944100000000000001", "8985200000000000001", "1"} {
bcd, err := encodeICCID(digits)
if err != nil {
t.Fatalf("encodeICCID(%q): %v", digits, err)
@@ -110,7 +110,7 @@ func TestICCIDRoundTrip(t *testing.T) {
t.Fatalf("round trip %q -> %q", digits, got)
}
}
if _, err := encodeICCID("894410004001280142571"); err == nil {
if _, err := encodeICCID("894410000000000000001"); err == nil {
t.Fatal("21-digit ICCID was accepted")
}
}
@@ -126,11 +126,11 @@ func TestEnableProfileRequestPads18DigitICCIDToTenOctets(t *testing.T) {
}
func TestDeleteProfileRequestAndResult(t *testing.T) {
request, err := buildDeleteProfileRequest("89441000400128014257")
request, err := buildDeleteProfileRequest("89441000000000000001")
if err != nil {
t.Fatal(err)
}
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100041082102475" {
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100000000000010" {
t.Fatalf("DeleteProfile request = %s", got)
}
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
@@ -144,28 +144,28 @@ func TestDeleteProfileRequestAndResult(t *testing.T) {
}
func TestSetNicknameRequestAndResult(t *testing.T) {
request, err := buildSetNicknameRequest("89441000400128014257", "Test")
request, err := buildSetNicknameRequest("89441000000000000001", "Test")
if err != nil {
t.Fatal(err)
}
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100041082102475900454657374" {
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100000000000010900454657374" {
t.Fatalf("SetNickname request = %s", got)
}
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
if !ok || result != 0 {
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
}
if _, err := buildSetNicknameRequest("89441000400128014257", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
if _, err := buildSetNicknameRequest("89441000000000000001", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
t.Fatalf("long nickname error = %v", err)
}
}
func TestDisableProfileRequestAndResult(t *testing.T) {
request, err := buildDisableProfileRequest("89441000400128014257")
request, err := buildDisableProfileRequest("89441000000000000001")
if err != nil {
t.Fatal(err)
}
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000410821024758101FF" {
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000000000000108101FF" {
t.Fatalf("DisableProfile request = %s", got)
}
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
@@ -210,7 +210,7 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
const target = "89492026266006792824"
client := &transcriptClient{steps: []clientStep{
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
{command: "AT+CCID", response: okResponse("+CCID: 8944100000000000001F")},
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
}}
manager, id := newStartedTestManager(t, client)
+76 -13
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 {
@@ -631,13 +685,11 @@ func (manager *Manager) Reboot(ctx context.Context, id string) error {
return err
}
// rebootForProfileSwitch is the post-EnableProfile modem reset. After the eUICC
// marks a new profile active, the modem keeps the old SIM cached and lands in
// SIM failure (-CME 13) until it is bounced. ESIMSwitchProfile has already
// released opMu by the time it calls this, so the reset is safe to take the
// lock. This mirrors Reboot but is separate so the call site can't recurse into
// a guarded-reset path.
func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) error {
// softResetForProfileSwitch resets the baseband SIM stack using a soft CFUN sequence
// (AT+CFUN=0 -> AT+CFUN=1/4) instead of rebooting the entire hardware module (AT+CFUN=1,1).
// This causes the baseband to reload the new eSIM profile files within ~1-2 seconds
// without disconnecting USB/PCIe or dropping serial communication ports.
func (manager *Manager) softResetForProfileSwitch(ctx context.Context, id string) error {
state, err := manager.lookup(id)
if err != nil {
return err
@@ -652,14 +704,25 @@ func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) e
manager.setResult(id, state, nil, err)
return err
}
commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
_, err = client.Execute(commandCtx, "AT+CFUN=1,1")
if closeErr := client.Close(); err == nil {
err = closeErr
// 1. Cycle SIM interface to minimum functionality / clear cached SIM files
_, _ = client.Execute(commandCtx, "AT+CFUN=0")
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
state.client = nil
state.preFlightMode = nil
// 2. Restore radio to trigger fresh USIM file reading
targetCFUN := "AT+CFUN=1"
if state.snapshot != nil && state.snapshot.FlightMode {
targetCFUN = "AT+CFUN=4"
}
_, err = client.Execute(commandCtx, targetCFUN)
manager.clearSnapshot(id, state)
manager.setResult(id, state, nil, err)
return err
+5 -5
View File
@@ -91,11 +91,11 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
wantPLMN string
wantCountry string
}{
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
{imsi: "234330000000001", wantPLMN: "23433", wantCountry: "GB"},
{imsi: "234150000000001", wantPLMN: "23415", wantCountry: "GB"},
{imsi: "234870000000001", wantPLMN: "23487", wantCountry: "GB"},
{imsi: "454000000000001", wantPLMN: "45400", wantCountry: "HK"},
{imsi: "310260000000001", wantPLMN: "310260", wantCountry: "US"},
}
for _, item := range tests {
plmn, name, country, ok := CarrierForIMSI(item.imsi)
+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)
}
}
+27 -4
View File
@@ -65,6 +65,32 @@ func (manager *Manager) readSnapshot(
if response, ok := optional("AT+CPIN?"); ok {
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
}
previousICCID = strings.TrimSpace(previousICCID)
if !snapshot.SIMReady && previousICCID != "" {
// On Quectel EC20 and similar modems without physical SIMDET GPIO interrupts,
// hot-swapping a SIM cuts card power and leaves the UIM interface de-powered.
// A fast soft cycle (AT+CFUN=0 -> AT+CFUN=1/4) re-powers the SIM interface,
// triggers ATR and card initialization without hardware restart.
_, _ = manager.command(ctx, client, "AT+CFUN=0")
select {
case <-ctx.Done():
return snapshot, ctx.Err()
case <-time.After(300 * time.Millisecond):
}
targetCFUN := "AT+CFUN=1"
if snapshot.FlightMode {
targetCFUN = "AT+CFUN=4"
}
_, _ = manager.command(ctx, client, targetCFUN)
select {
case <-ctx.Done():
return snapshot, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
if response, ok := optional("AT+CPIN?"); ok {
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
}
}
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
@@ -92,14 +118,11 @@ func (manager *Manager) readSnapshot(
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
}
previousICCID = strings.TrimSpace(previousICCID)
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
// A different physical SIM must never inherit the previous card's
// permission to use cellular RF. Disable RF before reading serving-cell
// or operator state; policy reconciliation will then start VoWiFi.
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
}
_, _ = manager.command(ctx, client, "AT+CFUN=4")
snapshot.SIMChanged = true
}
if response, ok := optional("AT+CIMI"); ok {
+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")
}
}
+1 -1
View File
@@ -580,7 +580,7 @@ func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
SequenceNumber: 12,
Event: "delete",
ICCID: "89441000400128014257",
ICCID: "8944100000000000001",
Address: "rsp.example.com",
AIDHex: "A0000005591010FFFFFFFF8900000100",
CanRetry: true,
+5 -5
View File
@@ -40,12 +40,12 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
DeviceID: "ec20_1",
Phase: "stopping",
ICCID: "89441000400128014257",
IMSI: "234159608751160",
ICCID: "8944100000000000001",
IMSI: "234150000000001",
TunnelReady: true,
IMSReady: true,
SMSReady: true,
LocalPhone: "+447386083638",
LocalPhone: "+447700900123",
PhoneNumberSource: "ims_p_associated_uri",
UpdatedAt: time.Now().UTC(),
}); err != nil {
@@ -60,7 +60,7 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
if got["vowifi_active"] != false {
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
}
if got["local_phone"] == "+447386083638" {
if got["local_phone"] == "+447700900123" {
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
}
runtime, ok := got["vowifi_runtime"].(map[string]any)
@@ -169,7 +169,7 @@ func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
}
for _, snapshot := range []*device.Snapshot{
{SIMStatus: "pin_required"},
{ICCID: "89441000400128014257"},
{ICCID: "8944100000000000001"},
{SIMReady: true},
} {
if !snapshotHasSIM(snapshot) {
+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:
+5 -5
View File
@@ -13,7 +13,7 @@ import (
"vocat/internal/vowifi"
)
const testProfileICCID = "89441000400128014257"
const testProfileICCID = "8944100000000000001"
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
t.Helper()
@@ -51,7 +51,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Vodafone UK","state_text":"Enabled"},
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
]
}`)
@@ -66,7 +66,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
}
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["8944100000000000001","89104100000028106378"]}`)
if response.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
}
@@ -80,11 +80,11 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
server, database, _ := newProfileBindingTestServer(t)
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
if first.Code != http.StatusOK {
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
}
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
if second.Code != http.StatusConflict {
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
}
+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
+66 -8
View File
@@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
s.writeStoreError(w, err)
return
}
for _, message := range messages {
if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
message.Read = true
_, _ = s.store.SaveSMSMessage(r.Context(), message)
unreadIDs := make([]int64, 0, len(messages))
for i := range messages {
if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") {
messages[i].Read = true
unreadIDs = append(unreadIDs, messages[i].ID)
}
}
if len(unreadIDs) > 0 {
if markErr := s.store.MarkSMSMessagesRead(r.Context(), unreadIDs); markErr != nil {
s.logger.Warn("mark SMS messages read failed", "error", markErr)
}
}
reverseSMS(messages)
@@ -363,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{
@@ -384,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",
@@ -393,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})
}
@@ -469,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 {
@@ -483,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",
@@ -492,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})
}
@@ -647,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,
@@ -664,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,
)
}
}
}
@@ -764,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")
}
+2 -2
View File
@@ -236,10 +236,10 @@ func (bot *telegramBot) getUpdates(
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
if callback := update.CallbackQuery; callback != nil {
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
_ = bot.answerCallback(ctx, config, callback.ID, "无权限")
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "无权限") }()
return
}
_ = bot.answerCallback(ctx, config, callback.ID, "")
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "") }()
bot.handleCallback(ctx, config, callback)
return
}
+3 -3
View File
@@ -122,7 +122,7 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
}
state := &vowifi.State{
ICCID: "previous-card",
PhoneNumber: "+447386083638",
PhoneNumber: "+447700900123",
}
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
t.Fatalf("stale or placeholder number leaked as %q", got)
@@ -135,10 +135,10 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
}
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
if got := telegramHomeCarrier("234330000000001"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
t.Fatalf("home carrier = %q", got)
}
if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
if got := telegramHomeCarrier("454000000000001", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
t.Fatalf("profile brand overrode home carrier = %q", got)
}
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
+28 -5
View File
@@ -128,7 +128,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
VALUES ('known', '89441000400128014257', 100);
VALUES ('known', '8944100000000000001', 100);
PRAGMA user_version = 11;
`); err != nil {
t.Fatal(err)
@@ -138,7 +138,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
}
database := openTestStore(t, path)
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
t.Fatalf("migrated binding = %+v, %v", binding, err)
}
@@ -579,6 +579,20 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
t.Fatalf("thread should be read: %+v", contacts)
}
// A subsequent periodic modem AT sync with raw unread state must not revert is_read back to 0.
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
Peer: "10086", Direction: "inbound", Body: "第一条(完整)",
Timestamp: base, Status: "received", Read: false,
}); err != nil {
t.Fatal(err)
}
contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
if err != nil || len(contacts) != 1 || contacts[0].UnreadCount != 0 {
t.Fatalf("thread read state must survive modem rescan: %+v", contacts)
}
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
if err != nil || deleted != 2 {
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
@@ -789,11 +803,11 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
t.Fatalf("CountryRule() = %+v, %v", rule, err)
}
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
DeviceID: "ec20-1", ICCID: "8944100000000000001", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
}); err != nil {
t.Fatal(err)
}
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
}
@@ -803,7 +817,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
}
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
if _, err := database.DeviceProxyBinding(ctx, "8944100000000000001"); !errors.Is(err, ErrNotFound) {
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
}
}
@@ -1003,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 {
+34 -9
View File
@@ -92,15 +92,16 @@ func saveSMSMessage(
if mergeErr != nil {
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
}
if existingErr == nil && !changed {
// This segment is already folded into the stored row (a periodic modem
// rescan redelivers every segment). Leave the row untouched so the
// durable id stays put and Telegram does not re-notify.
return existing, nil
}
value.Body = mergedBody
extra = mergedExtra
if existingErr == nil {
if !changed {
if value.Read != existing.Read {
if _, err := executor.ExecContext(ctx, `UPDATE sms_messages SET is_read = ?, updated_at = ? WHERE id = ?`, boolInt(value.Read), now.Unix(), existing.ID); err != nil {
return SMSMessage{}, fmt.Errorf("update concatenated SMS read state: %w", err)
}
existing.Read = value.Read
}
return existing, nil
}
// A new segment advanced the message. Replace the stale partial row so
// the merged row receives a fresh durable id; the Telegram id-cursor
// then surfaces the now-more-complete message exactly once. Carry
@@ -116,6 +117,8 @@ func saveSMSMessage(
value.Timestamp = existing.Timestamp
}
}
value.Body = mergedBody
extra = mergedExtra
}
if value.Timestamp.IsZero() {
value.Timestamp = now
@@ -171,7 +174,10 @@ func saveSMSMessage(
source = excluded.source,
parts_total = excluded.parts_total,
delivery_state = excluded.delivery_state,
is_read = excluded.is_read,
is_read = CASE
WHEN sms_messages.is_read = 1 THEN 1
ELSE excluded.is_read
END,
extra_json = excluded.extra_json,
updated_at = excluded.updated_at
`,
@@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead(
return affected, nil
}
func (s *Store) MarkSMSMessagesRead(ctx context.Context, ids []int64) error {
if len(ids) == 0 {
return nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, time.Now().UTC().Unix())
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
query := fmt.Sprintf("UPDATE sms_messages SET is_read = 1, updated_at = ? WHERE id IN (%s) AND is_read = 0", strings.Join(placeholders, ","))
_, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("mark SMS messages read: %w", err)
}
return nil
}
// ListSMSContacts derives contacts and thread counters from messages. No
// duplicated contact/thread table can drift out of sync with message history.
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
+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 {
+48 -23
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
}
@@ -396,8 +400,8 @@ func decimalString(value string) bool {
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
// attributes add specificity, so a constrained MVNO rule wins over its host
// PLMN without weakening the default match for unrelated subscriptions.
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
resolved := CarrierProfile{
func defaultCarrierProfile() CarrierProfile {
return CarrierProfile{
ID: CarrierProfileStandard,
MatchSource: "standard",
IKEProposal: IKEProposalModern,
@@ -408,6 +412,13 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
IMSDialURIScheme: "tel",
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
}
}
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
// attributes add specificity, so a constrained MVNO rule wins over its host
// PLMN without weakening the default match for unrelated subscriptions.
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
resolved := defaultCarrierProfile()
bestScore := -1
for _, rule := range carrierProfilesSnapshot() {
score, source, matched := matchCarrierProfileRule(rule, identity)
@@ -415,7 +426,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
continue
}
bestScore = score
resolved = applyCarrierProfileRule(resolved, rule, source, identity)
resolved = applyCarrierProfileRule(defaultCarrierProfile(), rule, source, identity)
}
return resolved
}
@@ -458,6 +469,8 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
score += 100
sources = append(sources, "hplmn")
hasHomePLMNMatch = true
} else if identity.HomeMCC != "" && identity.HomeMNC != "" {
return 0, "", false
}
}
hasSelectorMatch := false
@@ -470,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
@@ -487,7 +504,7 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
score += selector.weight
sources = append(sources, selector.name)
hasSelectorMatch = true
} else if !hasHomePLMNMatch {
} else if !hasHomePLMNMatch || selector.name == "gid1" || selector.name == "gid2" {
return 0, "", false
}
}
@@ -499,6 +516,8 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
score += 20
sources = append(sources, "spn")
hasSelectorMatch = true
} else {
return 0, "", false
}
}
if !hasHomePLMNMatch && !hasSelectorMatch {
@@ -573,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
}
@@ -609,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
}
@@ -708,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
@@ -722,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"
+98
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,7 +104,70 @@ 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")
}
}
func TestMVNOParentNetworkRouting(t *testing.T) {
// Giffgaff on O2 UK
giffgaff := ResolveCarrierProfile(SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
})
if giffgaff.RouteMCC != "234" || giffgaff.RouteMNC != "10" {
t.Fatalf("giffgaff Route PLMN = %s-%s, want 234-10", giffgaff.RouteMCC, giffgaff.RouteMNC)
}
// VOXI on Vodafone UK
voxi := ResolveCarrierProfile(SIMIdentity{
IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI",
})
if !strings.Contains(voxi.ID, "voxi") || voxi.RouteMCC != "234" || voxi.RouteMNC != "15" {
t.Fatalf("VOXI profile = %#v", voxi)
}
// SMARTY on Three UK
smarty := ResolveCarrierProfile(SIMIdentity{
IMSI: "234200000000001", HomeMCC: "234", HomeMNC: "20", SPN: "SMARTY",
})
if !strings.Contains(smarty.ID, "smarty") || smarty.RouteMCC != "234" || smarty.RouteMNC != "20" {
t.Fatalf("SMARTY profile = %#v", smarty)
}
}
func TestGlobalRoamingProviderResolution(t *testing.T) {
// Truphone / BetterRoaming global 90143
truphone := ResolveCarrierProfile(SIMIdentity{
IMSI: "901430000000001", HomeMCC: "901", HomeMNC: "43",
})
if (!strings.Contains(truphone.ID, "truphone") && !strings.Contains(truphone.ID, "1global")) || truphone.EPDG != "epdg.eps.truphone.net" {
t.Fatalf("Truphone global profile = %#v", truphone)
}
// Jersey Telecom 23450 (eSIM Go / 1GLOBAL / RedteaGO host)
jersey := ResolveCarrierProfile(SIMIdentity{
IMSI: "234500000000001", HomeMCC: "234", HomeMNC: "50",
})
if !strings.Contains(jersey.ID, "jersey-telecom") || jersey.EPDG != "epdg.epc.mnc050.mcc234.pub.3gppnetwork.org" {
t.Fatalf("Jersey Telecom profile = %#v", jersey)
}
}
func TestCTExcelMVNOResolution(t *testing.T) {
ctexcel := ResolveCarrierProfile(SIMIdentity{
IMSI: "234330000000001",
ICCID: "8944300000000000001",
SPN: "CTExcel",
HomeMCC: "234",
HomeMNC: "33",
})
if ctexcel.ID != "ipcc-ctexcel-23433" {
t.Fatalf("CTExcel profile ID = %q, want ipcc-ctexcel-23433", ctexcel.ID)
}
if ctexcel.IMSDialURIScheme != "sip" || !ctexcel.IMSUserEqPhone {
t.Fatalf("CTExcel dial URI scheme = %q, userEqPhone = %v", ctexcel.IMSDialURIScheme, ctexcel.IMSUserEqPhone)
}
}
+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",
+180 -8
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": {
@@ -4054,6 +4074,19 @@
"home_plmns": [
"23450"
]
},
"route": {
"mcc": "234",
"mnc": "50"
},
"epdg": {
"hostname": "epdg.epc.mnc050.mcc234.pub.3gppnetwork.org"
},
"ike": {
"proposal": "modern"
},
"ims": {
"ipsec_encryption": "aes-cbc"
}
},
{
@@ -5724,13 +5757,27 @@
},
{
"id": "ipcc-giffgaff-23410",
"match": {
"home_plmns": [
"23410"
],
"gid1_prefixes": [
"508"
]
"match_any": [
{
"home_plmns": [
"23410"
],
"gid1_prefixes": [
"508"
]
},
{
"home_plmns": [
"23410"
],
"spns": [
"giffgaff"
]
}
],
"route": {
"mcc": "234",
"mnc": "10"
},
"epdg": {
"hostname": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org"
@@ -5738,6 +5785,85 @@
"ike": {
"proposal": "modern"
},
"ims": {
"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"
]
}
}
},
{
"id": "ipcc-voxi-23415",
"match_any": [
{
"home_plmns": [
"23415"
],
"spns": [
"VOXI"
]
},
{
"home_plmns": [
"23415"
],
"gid1_prefixes": [
"4E"
]
}
],
"route": {
"mcc": "234",
"mnc": "15"
},
"epdg": {
"hostname": "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"
},
"ike": {
"proposal": "modern"
},
"ims": {
"ipsec_encryption": "aes-cbc"
}
},
{
"id": "ipcc-smarty-23420",
"match_any": [
{
"home_plmns": [
"23420"
],
"spns": [
"SMARTY"
]
},
{
"home_plmns": [
"23420"
],
"gid1_prefixes": [
"534D41525459"
]
}
],
"route": {
"mcc": "234",
"mnc": "20"
},
"epdg": {
"hostname": "epdg.epc.mnc020.mcc234.pub.3gppnetwork.org"
},
"ike": {
"proposal": "modern"
},
"ims": {
"ipsec_encryption": "aes-cbc"
}
@@ -6356,6 +6482,39 @@
]
}
},
{
"id": "ipcc-ctexcel-23433",
"match": {
"home_plmns": [
"23433",
"23430"
],
"spns": [
"CTExcel",
"CTExcel UK",
"China Telecom",
"China Telecom UK"
],
"iccid_prefixes": [
"894430"
]
},
"route": {
"mcc": "234",
"mnc": "33"
},
"epdg": {
"hostname": "epdg.epc.mnc033.mcc234.pub.3gppnetwork.org"
},
"ike": {
"proposal": "modern"
},
"ims": {
"dial_uri_scheme": "sip",
"user_eq_phone": true,
"ipsec_encryption": "aes-cbc"
}
},
{
"id": "ipcc-ee-23433",
"match": {
@@ -9660,6 +9819,19 @@
"gid1_prefixes": [
"547275554B3030656E"
]
},
{
"home_plmns": [
"90143",
"90128"
]
},
{
"spns": [
"Truphone",
"BetterRoaming",
"1GLOBAL"
]
}
],
"epdg": {
@@ -11642,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,
) {
+233
View File
@@ -280,6 +280,239 @@ func decryptPayloads(
return header, payloads, nil
}
const defaultIKEFragmentSize = 1100
func encryptPayloadsFragmented(
header ikeHeader,
inner []payload,
suite negotiatedSuite,
encryptionKey []byte,
integrityKey []byte,
maxFragmentSize int,
random io.Reader,
) ([][]byte, error) {
if random == nil {
random = rand.Reader
}
if maxFragmentSize <= 0 {
maxFragmentSize = defaultIKEFragmentSize
}
first, plaintext, err := marshalPayloadChain(inner)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, fmt.Errorf("ike: initialize AES: %w", err)
}
_, checksumLength, err := suite.integrityLengths()
if err != nil {
return nil, err
}
maxChunk := maxFragmentSize - ikeHeaderLength - 8 - block.BlockSize() - block.BlockSize() - checksumLength
if maxChunk < 64 {
maxChunk = 64
}
var chunks [][]byte
for len(plaintext) > 0 {
take := len(plaintext)
if take > maxChunk {
take = maxChunk
}
chunks = append(chunks, plaintext[:take])
plaintext = plaintext[take:]
}
totalFragments := uint16(len(chunks))
if totalFragments == 0 {
totalFragments = 1
chunks = [][]byte{nil}
}
var packets [][]byte
for index, chunk := range chunks {
fragNum := uint16(index + 1)
fragNext := uint8(payloadNone)
if fragNum == 1 {
fragNext = first
}
paddingLength := block.BlockSize() - (len(chunk)+1)%block.BlockSize()
if paddingLength == block.BlockSize() {
paddingLength = 0
}
padding := make([]byte, paddingLength)
if _, err := io.ReadFull(random, padding); err != nil {
return nil, fmt.Errorf("ike: generate encrypted payload padding: %w", err)
}
paddedChunk := append(append([]byte(nil), chunk...), padding...)
paddedChunk = append(paddedChunk, byte(paddingLength))
iv := make([]byte, block.BlockSize())
if _, err := io.ReadFull(random, iv); err != nil {
return nil, fmt.Errorf("ike: generate encrypted payload IV: %w", err)
}
ciphertext := make([]byte, len(paddedChunk))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, paddedChunk)
skfLength := 4 + 4 + len(iv) + len(ciphertext) + checksumLength
if skfLength > 65535 {
return nil, errors.New("ike: encrypted fragment exceeds 65535 bytes")
}
body := make([]byte, skfLength)
body[0] = fragNext
body[1] = 0
binary.BigEndian.PutUint16(body[2:4], uint16(skfLength))
binary.BigEndian.PutUint16(body[4:6], fragNum)
binary.BigEndian.PutUint16(body[6:8], totalFragments)
copy(body[8:], iv)
copy(body[8+len(iv):], ciphertext)
fragHeader := header
fragHeader.NextPayload = payloadEncryptedFragment
packet := fragHeader.marshal(body)
checksum, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
if err != nil {
return nil, err
}
copy(packet[len(packet)-checksumLength:], checksum)
packets = append(packets, packet)
}
return packets, nil
}
func decryptSingleFragment(
packet []byte,
suite negotiatedSuite,
encryptionKey []byte,
integrityKey []byte,
) (ikeHeader, uint8, uint16, uint16, []byte, error) {
header, body, err := parseIKEPacket(packet)
if err != nil {
return ikeHeader{}, 0, 0, 0, nil, err
}
if header.NextPayload != payloadEncryptedFragment || len(body) < 8 {
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: message is not an encrypted IKE fragment", errUnexpectedPacket)
}
skfLength := int(binary.BigEndian.Uint16(body[2:4]))
if skfLength != len(body) {
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment length mismatch", errMalformedPacket)
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("ike: initialize AES: %w", err)
}
_, checksumLength, err := suite.integrityLengths()
if err != nil {
return ikeHeader{}, 0, 0, 0, nil, err
}
if len(body) < 8+block.BlockSize()+block.BlockSize()+checksumLength {
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment is too short", errMalformedPacket)
}
expected, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
if err != nil {
return ikeHeader{}, 0, 0, 0, nil, err
}
actual := packet[len(packet)-checksumLength:]
if subtle.ConstantTimeCompare(actual, expected) != 1 {
return ikeHeader{}, 0, 0, 0, nil, errIntegrityMismatch
}
fragNext := body[0]
fragNum := binary.BigEndian.Uint16(body[4:6])
totalFrags := binary.BigEndian.Uint16(body[6:8])
if fragNum == 0 || totalFrags == 0 || fragNum > totalFrags {
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid fragment numbers %d/%d", errMalformedPacket, fragNum, totalFrags)
}
ivStart := 8
ciphertextStart := ivStart + block.BlockSize()
ciphertextEnd := len(body) - checksumLength
ciphertext := body[ciphertextStart:ciphertextEnd]
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: fragment ciphertext is not block aligned", errMalformedPacket)
}
plaintext := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, body[ivStart:ciphertextStart]).CryptBlocks(plaintext, ciphertext)
paddingLength := int(plaintext[len(plaintext)-1])
if paddingLength+1 > len(plaintext) {
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid encrypted fragment padding", errMalformedPacket)
}
plaintext = plaintext[:len(plaintext)-paddingLength-1]
return header, fragNext, fragNum, totalFrags, plaintext, nil
}
func decryptPayloadsAny(
packet []byte,
fragments [][]byte,
suite negotiatedSuite,
encryptionKey []byte,
integrityKey []byte,
) (ikeHeader, []payload, error) {
if len(fragments) > 0 {
var (
firstHeader ikeHeader
firstNext uint8
totalExpected uint16
plaintexts = make(map[uint16][]byte)
)
for _, fragPacket := range fragments {
hdr, next, num, total, plain, err := decryptSingleFragment(fragPacket, suite, encryptionKey, integrityKey)
if err != nil {
return ikeHeader{}, nil, err
}
if totalExpected == 0 {
firstHeader = hdr
totalExpected = total
} else if total != totalExpected || hdr.MessageID != firstHeader.MessageID || hdr.Exchange != firstHeader.Exchange {
return ikeHeader{}, nil, fmt.Errorf("%w: inconsistent fragment headers", errMalformedPacket)
}
if num == 1 {
firstNext = next
}
plaintexts[num] = plain
}
if uint16(len(plaintexts)) != totalExpected {
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragments: received %d of %d", errMalformedPacket, len(plaintexts), totalExpected)
}
var fullPlaintext []byte
for i := uint16(1); i <= totalExpected; i++ {
chunk, ok := plaintexts[i]
if !ok {
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragment %d", errMalformedPacket, i)
}
fullPlaintext = append(fullPlaintext, chunk...)
}
payloads, err := parsePayloadChain(firstNext, fullPlaintext)
if err != nil {
return ikeHeader{}, nil, err
}
return firstHeader, payloads, nil
}
header, _, err := parseIKEPacket(packet)
if err != nil {
return ikeHeader{}, nil, err
}
if header.NextPayload == payloadEncryptedFragment {
hdr, next, num, total, plain, err := decryptSingleFragment(packet, suite, encryptionKey, integrityKey)
if err != nil {
return ikeHeader{}, nil, err
}
if num != 1 || total != 1 {
return ikeHeader{}, nil, fmt.Errorf("%w: standalone fragment with total=%d", errMalformedPacket, total)
}
payloads, err := parsePayloadChain(next, plain)
if err != nil {
return ikeHeader{}, nil, err
}
return hdr, payloads, nil
}
return decryptPayloads(packet, suite, encryptionKey, integrityKey)
}
var modpPrimes = map[uint16]string{
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
+77
View File
@@ -113,3 +113,80 @@ func TestIKEKeyDerivationSeparatesDirections(t *testing.T) {
t.Fatal("initiator and responder keys were not separated")
}
}
func TestRFC7383FragmentationAndReassembly(t *testing.T) {
suite := legacyTestSuite()
encryptionKey := bytes.Repeat([]byte{0x11}, 16)
integrityKey := bytes.Repeat([]byte{0x22}, 20)
header := ikeHeader{
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
ResponderSPI: [8]byte{8, 7, 6, 5, 4, 3, 2, 1},
Exchange: exchangeIKEAuth,
Flags: flagInitiator,
MessageID: 9,
}
largeCertData := bytes.Repeat([]byte{0xAB, 0xCD, 0xEF, 0x01}, 400) // 1600 bytes
inner := []payload{
{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u', 's', 'e', 'r'}},
{Type: payloadCert, Body: largeCertData},
{Type: payloadAuth, Body: bytes.Repeat([]byte{0x55}, 64)},
}
// Fragment into chunks with max fragment size 600 bytes
packets, err := encryptPayloadsFragmented(
header,
inner,
suite,
encryptionKey,
integrityKey,
600,
bytes.NewReader(bytes.Repeat([]byte{0x77}, 1024)),
)
if err != nil {
t.Fatalf("encryptPayloadsFragmented() error = %v", err)
}
if len(packets) < 3 {
t.Fatalf("expected at least 3 fragments for large payload, got %d", len(packets))
}
for i, pkt := range packets {
hdr, body, parseErr := parseIKEPacket(pkt)
if parseErr != nil {
t.Fatalf("fragment %d parse error: %v", i+1, parseErr)
}
if hdr.NextPayload != payloadEncryptedFragment {
t.Fatalf("fragment %d NextPayload = %d, want %d (payloadEncryptedFragment)", i+1, hdr.NextPayload, payloadEncryptedFragment)
}
if len(body) < 8 {
t.Fatalf("fragment %d body too short", i+1)
}
}
// Decrypt and reassemble
decodedHeader, decoded, err := decryptPayloadsAny(nil, packets, suite, encryptionKey, integrityKey)
if err != nil {
t.Fatalf("decryptPayloadsAny() error = %v", err)
}
if decodedHeader.MessageID != header.MessageID || len(decoded) != len(inner) {
t.Fatalf("reassembled payload mismatch: header=%#v, count=%d, want=%d", decodedHeader, len(decoded), len(inner))
}
for index := range inner {
if decoded[index].Type != inner[index].Type || !bytes.Equal(decoded[index].Body, inner[index].Body) {
t.Fatalf("decoded payload %d = %#v, want %#v", index, decoded[index], inner[index])
}
}
// Test tamper detection on second fragment
tamperedPackets := make([][]byte, len(packets))
for i := range packets {
tamperedPackets[i] = append([]byte(nil), packets[i]...)
}
tamperedPackets[1][len(tamperedPackets[1])-1] ^= 0x55
if _, _, err := decryptPayloadsAny(nil, tamperedPackets, suite, encryptionKey, integrityKey); !errors.Is(err, errIntegrityMismatch) {
t.Fatalf("tampered fragment decrypt error = %v, want errIntegrityMismatch", err)
}
}
+7 -2
View File
@@ -264,8 +264,13 @@ func permanentAKAIdentity(identity vowifi.SIMIdentity) ([]byte, error) {
return nil, errors.New("ike: IMSI contains a non-digit")
}
}
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimSpace(identity.HomeMNC)
profile := vowifi.ResolveCarrierProfile(identity)
mcc := strings.TrimSpace(profile.RouteMCC)
mnc := strings.TrimSpace(profile.RouteMNC)
if mcc == "" || mnc == "" {
mcc = strings.TrimSpace(identity.HomeMCC)
mnc = strings.TrimSpace(identity.HomeMNC)
}
if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) {
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
}
+20 -9
View File
@@ -29,21 +29,32 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
resolver = net.DefaultResolver
}
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
validSystemAddresses := filterValidPublicEPDGAddresses(addresses)
if systemErr == nil && len(validSystemAddresses) > 0 {
return validSystemAddresses, nil
hostsToTry := []string{normalized}
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
hostsToTry = append(hostsToTry, alt)
}
var systemErr error
for _, targetHost := range hostsToTry {
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
}
} else {
systemErr = err
}
}
subnet := vowifi.EPDGDNSClientSubnet(normalized)
client := &http.Client{Timeout: 8 * time.Second}
var fallbackErr error
hostsToTry := []string{normalized}
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
hostsToTry = append(hostsToTry, alt)
}
for _, targetHost := range hostsToTry {
var fallback []net.IPAddr
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
+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(
+103 -32
View File
@@ -187,6 +187,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
{Type: payloadNonce, Body: initiatorNonce},
makeNotify(notifyNATSource, sourceHash),
makeNotify(notifyNATDestination, destinationHash),
makeNotify(notifyFragmentationSupported, nil),
}
var (
initRequest []byte
@@ -243,6 +244,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
}
break
}
peerSupportsFragmentation := hasNotifyType(initResponsePayloads, notifyFragmentationSupported)
saPayload, err := onePayload(initResponsePayloads, payloadSA)
if err != nil {
return nil, err
@@ -345,21 +347,19 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
Flags: flagInitiator,
MessageID: 1,
}
authRequest, err := encryptPayloads(authHeader, firstAuthPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
if err != nil {
return nil, err
}
authResponse, err := transport.RoundTrip(ctx, authRequest)
if err != nil {
return nil, err
}
authResponseHeader, authResponsePayloads, err := decryptAndValidate(
authResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, 1, ikeSuite, keys,
_, authResponsePayloads, err := sendAndReceiveIKEPayloads(
ctx,
transport,
authHeader,
firstAuthPayloads,
ikeSuite,
keys,
peerSupportsFragmentation,
provider.config.Random,
)
if err != nil {
return nil, err
}
_ = authResponseHeader
serverName := strings.TrimSpace(provider.config.ServerName)
if serverName == "" {
serverName = epdg
@@ -409,27 +409,27 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
requestPayloads = append(requestPayloads, deviceIdentity)
}
}
eapRequest, err := encryptPayloads(ikeHeader{
eapHeader := ikeHeader{
InitiatorSPI: initiatorSPI,
ResponderSPI: responseHeader.ResponderSPI,
Exchange: exchangeIKEAuth,
Flags: flagInitiator,
MessageID: messageID,
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
if err != nil {
return nil, err
}
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
return nil, notifyErr
} else if requested {
deviceIdentityPending = true
}
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
if err != nil {
return nil, err
}
_, currentPayloads, err = decryptAndValidate(
eapResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
_, currentPayloads, err = sendAndReceiveIKEPayloads(
ctx,
transport,
eapHeader,
requestPayloads,
ikeSuite,
keys,
peerSupportsFragmentation,
provider.config.Random,
)
if err != nil {
return nil, err
@@ -454,22 +454,22 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
}
messageID++
cleanupMessageID = messageID + 1
finalRequest, err := encryptPayloads(ikeHeader{
finalHeader := ikeHeader{
InitiatorSPI: initiatorSPI,
ResponderSPI: responseHeader.ResponderSPI,
Exchange: exchangeIKEAuth,
Flags: flagInitiator,
MessageID: messageID,
}, []payload{initiatorAUTH}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
if err != nil {
return nil, err
}
finalResponse, err := transport.RoundTrip(ctx, finalRequest)
if err != nil {
return nil, err
}
_, finalPayloads, err := decryptAndValidate(
finalResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
_, finalPayloads, err := sendAndReceiveIKEPayloads(
ctx,
transport,
finalHeader,
[]payload{initiatorAUTH},
ikeSuite,
keys,
peerSupportsFragmentation,
provider.config.Random,
)
if err != nil {
return nil, err
@@ -763,7 +763,7 @@ func decryptAndValidate(
suite negotiatedSuite,
keys ikeKeys,
) (ikeHeader, []payload, error) {
header, payloads, err := decryptPayloads(packet, suite, keys.SKer, keys.SKar)
header, payloads, err := decryptPayloadsAny(packet, nil, suite, keys.SKer, keys.SKar)
if err != nil {
return ikeHeader{}, nil, err
}
@@ -778,6 +778,77 @@ func decryptAndValidate(
return header, payloads, nil
}
func decryptAndValidateFragments(
packets [][]byte,
initiatorSPI [8]byte,
responderSPI [8]byte,
exchange uint8,
messageID uint32,
suite negotiatedSuite,
keys ikeKeys,
) (ikeHeader, []payload, error) {
if len(packets) == 0 {
return ikeHeader{}, nil, errors.New("ike: empty exchange response")
}
if len(packets) == 1 {
return decryptAndValidate(packets[0], initiatorSPI, responderSPI, exchange, messageID, suite, keys)
}
header, payloads, err := decryptPayloadsAny(nil, packets, suite, keys.SKer, keys.SKar)
if err != nil {
return ikeHeader{}, nil, err
}
if header.InitiatorSPI != initiatorSPI ||
header.ResponderSPI != responderSPI ||
header.Exchange != exchange ||
header.MessageID != messageID ||
header.Flags&flagResponse == 0 ||
header.Flags&flagInitiator != 0 {
return ikeHeader{}, nil, fmt.Errorf("%w: encrypted response header does not match the request", errUnexpectedPacket)
}
return header, payloads, nil
}
func sendAndReceiveIKEPayloads(
ctx context.Context,
transport datagramTransport,
header ikeHeader,
payloads []payload,
suite negotiatedSuite,
keys ikeKeys,
peerSupportsFragmentation bool,
random io.Reader,
) (ikeHeader, []payload, error) {
var outboundPackets [][]byte
var err error
if peerSupportsFragmentation {
outboundPackets, err = encryptPayloadsFragmented(header, payloads, suite, keys.SKei, keys.SKai, defaultIKEFragmentSize, random)
} else {
pkt, encryptErr := encryptPayloads(header, payloads, suite, keys.SKei, keys.SKai, random)
if encryptErr != nil {
return ikeHeader{}, nil, encryptErr
}
outboundPackets = [][]byte{pkt}
}
if err != nil {
return ikeHeader{}, nil, err
}
inboundPackets, err := transport.RoundTripExchange(ctx, outboundPackets)
if err != nil {
return ikeHeader{}, nil, err
}
return decryptAndValidateFragments(inboundPackets, header.InitiatorSPI, header.ResponderSPI, header.Exchange, header.MessageID, suite, keys)
}
func hasNotifyType(payloads []payload, notifyType uint16) bool {
for _, item := range payloadsOfType(payloads, payloadNotify) {
kind, _, err := parseNotify(item)
if err == nil && kind == notifyType {
return true
}
}
return false
}
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
type invalidKEPayloadError struct {
+14 -3
View File
@@ -76,7 +76,7 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
return nil
}
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
func (transport *firstAuthCaptureTransport) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
transport.calls++
if len(transport.cookieChallenge) > 0 {
switch transport.calls {
@@ -106,6 +106,17 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
}
}
func (transport *firstAuthCaptureTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
if len(packets) == 0 {
return nil, errors.New("test: empty outbound packets")
}
resp, err := transport.RoundTrip(ctx, packets[0])
if err != nil {
return nil, err
}
return [][]byte{resp}, nil
}
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
header, _, err := parseIKEPacket(packet)
if err != nil {
@@ -146,8 +157,8 @@ func (transport *firstAuthCaptureTransport) verifyIKECookie(packet []byte) error
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
}
cookies := payloadsOfType(payloads, payloadNotify)
if len(cookies) != 3 {
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", len(cookies))
if len(cookies) != 4 {
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 4", len(cookies))
}
found := false
for _, item := range cookies {
+1 -1
View File
@@ -39,7 +39,7 @@ func newSessionRelay(
keepalive time.Duration,
) *sessionRelay {
if keepalive <= 0 {
keepalive = 20 * time.Second
keepalive = 15 * time.Second
}
ctx, cancel := context.WithCancel(context.Background())
relay := &sessionRelay{
+11
View File
@@ -3,6 +3,7 @@ package ike
import (
"bytes"
"context"
"errors"
"net"
"sync"
"sync/atomic"
@@ -49,6 +50,16 @@ func (*fakeSessionTransport) Float(context.Context) error { return nil }
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
return nil, context.DeadlineExceeded
}
func (t *fakeSessionTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
if len(packets) == 0 {
return nil, errors.New("empty outbound packets")
}
resp, err := t.RoundTrip(ctx, packets[0])
if err != nil {
return nil, err
}
return [][]byte{resp}, nil
}
func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error {
return transport.SendSessionPacket(ctx, packet, false)
}
+127 -43
View File
@@ -20,6 +20,7 @@ type datagramTransport interface {
RemoteAddr() *net.UDPAddr
Float(context.Context) error
RoundTrip(context.Context, []byte) ([]byte, error)
RoundTripExchange(context.Context, [][]byte) ([][]byte, error)
SendESP(context.Context, []byte) error
ReceiveESP(context.Context, []byte) (int, error)
SendSessionPacket(context.Context, []byte, bool) error
@@ -96,6 +97,29 @@ func roundTripDatagram(
read func([]byte, time.Time) (int, error),
packet []byte,
) ([]byte, error) {
writeAll := func(values [][]byte) error {
if len(values) > 0 {
return write(values[0])
}
return nil
}
responses, err := roundTripFragments(ctx, timeout, writeAll, read, [][]byte{packet})
if err != nil {
return nil, err
}
if len(responses) == 0 {
return nil, errors.New("ike: empty datagram response")
}
return responses[0], nil
}
func roundTripFragments(
ctx context.Context,
timeout time.Duration,
writeAll func([][]byte) error,
read func([]byte, time.Time) (int, error),
packets [][]byte,
) ([][]byte, error) {
if ctx == nil {
ctx = context.Background()
}
@@ -110,20 +134,44 @@ func roundTripDatagram(
if err := ctx.Err(); err != nil {
return nil, err
}
if err := write(packet); err != nil {
if err := writeAll(packets); err != nil {
return nil, err
}
attemptDeadline := time.Now().Add(interval)
if deadline.Before(attemptDeadline) {
attemptDeadline = deadline
}
var (
totalExpected uint16
fragments = make(map[uint16][]byte)
)
for time.Now().Before(attemptDeadline) {
if err := ctx.Err(); err != nil {
return nil, err
}
n, err := read(buffer, attemptDeadline)
if err == nil {
return append([]byte(nil), buffer[:n]...), nil
pkt := append([]byte(nil), buffer[:n]...)
header, body, parseErr := parseIKEPacket(pkt)
if parseErr == nil && header.NextPayload == payloadEncryptedFragment && len(body) >= 8 {
fragNum := binary.BigEndian.Uint16(body[4:6])
total := binary.BigEndian.Uint16(body[6:8])
if total > 1 {
if totalExpected == 0 {
totalExpected = total
}
fragments[fragNum] = pkt
if uint16(len(fragments)) == totalExpected {
res := make([][]byte, 0, totalExpected)
for i := uint16(1); i <= totalExpected; i++ {
res = append(res, fragments[i])
}
return res, nil
}
continue
}
}
return [][]byte{pkt}, nil
}
if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() {
lastErr = err
@@ -222,25 +270,47 @@ func (transport *directUDP) Float(ctx context.Context) error {
}
func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
if err != nil {
return nil, err
}
if len(responses) == 0 {
return nil, errors.New("ike: empty exchange response")
}
return responses[0], nil
}
func (transport *directUDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
transport.mu.Lock()
defer transport.mu.Unlock()
if transport.conn == nil {
return nil, errors.New("ike: UDP transport is closed")
}
requestHeader, _, err := parseIKEPacket(packet)
if len(packets) == 0 {
return nil, errors.New("ike: outbound packet list is empty")
}
requestHeader, _, err := parseIKEPacket(packets[0])
if err != nil {
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
}
wirePacket := packet
if transport.floated {
wirePacket = append([]byte{0, 0, 0, 0}, packet...)
}
write := func(value []byte) error {
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
return err
var wirePackets [][]byte
for _, pkt := range packets {
wire := pkt
if transport.floated {
wire = append([]byte{0, 0, 0, 0}, pkt...)
}
_, err := transport.conn.Write(value)
return err
wirePackets = append(wirePackets, wire)
}
writeAll := func(values [][]byte) error {
for _, value := range values {
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
return err
}
if _, err := transport.conn.Write(value); err != nil {
return err
}
}
return nil
}
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
for {
@@ -252,10 +322,6 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
return 0, err
}
if transport.floated {
// IKE and ESP legitimately share UDP/4500. An ESP packet can
// arrive immediately before the IKE response that completes
// CHILD_SA setup; discard it here and keep the same absolute
// attempt deadline while waiting for marked IKE.
if !hasNonESPMarker(buffer[:n]) {
continue
}
@@ -268,7 +334,7 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
return n, nil
}
}
return roundTripDatagram(ctx, transport.config.Timeout, write, read, wirePacket)
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, wirePackets)
}
func (transport *directUDP) SendESP(ctx context.Context, packet []byte) error {
@@ -561,12 +627,26 @@ func (transport *socks5UDP) Float(_ context.Context) error {
}
func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
if err != nil {
return nil, err
}
if len(responses) == 0 {
return nil, errors.New("ike: empty exchange response")
}
return responses[0], nil
}
func (transport *socks5UDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
transport.mu.Lock()
defer transport.mu.Unlock()
if transport.udp == nil {
return nil, errors.New("ike: SOCKS5 UDP transport is closed")
}
requestHeader, _, err := parseIKEPacket(packet)
if len(packets) == 0 {
return nil, errors.New("ike: outbound packet list is empty")
}
requestHeader, _, err := parseIKEPacket(packets[0])
if err != nil {
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
}
@@ -576,18 +656,18 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
var lastErr error
var cookieResponse []byte
var cookieResponse [][]byte
for _, candidate := range transport.remotes {
transport.remote = cloneUDPAddr(candidate)
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
responses, attemptErr := transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
if attemptErr == nil {
if ikeInitResponseHasCookie(response) {
if len(responses) > 0 && ikeInitResponseHasCookie(responses[0]) {
if cookieResponse == nil {
cookieResponse = append([]byte(nil), response...)
cookieResponse = responses
}
continue
}
return response, nil
return responses, nil
}
lastErr = attemptErr
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
@@ -599,24 +679,32 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
}
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
}
return transport.roundTripLocked(ctx, packet, requestHeader)
return transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
}
func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte, requestHeader ikeHeader) ([]byte, error) {
wireIKE := packet
if transport.floated {
wireIKE = append([]byte{0, 0, 0, 0}, packet...)
}
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
if err != nil {
return nil, err
}
write := func(value []byte) error {
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
return err
func (transport *socks5UDP) roundTripFragmentsLocked(ctx context.Context, packets [][]byte, requestHeader ikeHeader) ([][]byte, error) {
var datagrams [][]byte
for _, pkt := range packets {
wireIKE := pkt
if transport.floated {
wireIKE = append([]byte{0, 0, 0, 0}, pkt...)
}
_, err := transport.udp.Write(value)
return err
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
if err != nil {
return nil, err
}
datagrams = append(datagrams, datagram)
}
writeAll := func(values [][]byte) error {
for _, value := range values {
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
return err
}
if _, err := transport.udp.Write(value); err != nil {
return err
}
}
return nil
}
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
for {
@@ -630,10 +718,6 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
return 0, err
}
if transport.floated {
// The relay can deliver ESP before the marked IKE response on
// the same UDP/4500 association. Do not accept it as IKE, and
// do not abort the exchange; keep waiting within the original
// deadline.
if !hasNonESPMarker(payload) {
continue
}
@@ -646,7 +730,7 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
return len(payload), nil
}
}
return roundTripDatagram(ctx, transport.config.Timeout, write, read, datagram)
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, datagrams)
}
func isNetworkTimeout(err error) bool {
+14 -12
View File
@@ -31,9 +31,10 @@ const (
payloadDelete = 42
payloadTSi = 44
payloadTSr = 45
payloadEncrypted = 46
payloadCP = 47
payloadEAP = 48
payloadEncrypted = 46
payloadCP = 47
payloadEAP = 48
payloadEncryptedFragment = 53
protocolIKE = 1
protocolESP = 3
@@ -55,15 +56,16 @@ const (
dhMODP2048 = 14
transformAttributeKeyLen = 14
notifyInitialContact = 16384
notifyMOBIKESupported = 16396
notifyNATSource = 16388
notifyNATDestination = 16389
notifyCookie = 16390
notifyEAPOnlyAuth = 16417
notifyDeviceIdentity = 41101
notifyInvalidKE = 17
notifyNoProposal = 14
notifyInitialContact = 16384
notifyMOBIKESupported = 16396
notifyNATSource = 16388
notifyNATDestination = 16389
notifyCookie = 16390
notifyEAPOnlyAuth = 16417
notifyFragmentationSupported = 16430
notifyDeviceIdentity = 41101
notifyInvalidKE = 17
notifyNoProposal = 14
)
var (
+44 -22
View File
@@ -115,8 +115,10 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
"P-Preferred-Identity: <"+preferredIdentity+">",
"P-Preferred-Service: "+mmtelServiceURN,
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
)
lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
"Supported: 100rel, timer, replaces",
"Session-Expires: 1800;refresher=uac",
@@ -146,6 +148,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS call started",
"category", "call",
"device_id", session.request.DeviceID,
"direction", "outgoing",
"identity_source", identitySource,
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
@@ -228,6 +232,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
} else {
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode,
"error", safeSIPDiagnostic(ackErr.Error()),
@@ -368,6 +374,14 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
session.callMu.Lock()
session.calls[callID] = call
session.callMu.Unlock()
if session.provider != nil && session.provider.config.Logger != nil {
session.provider.config.Logger.Info("IMS incoming call received",
"category", "call",
"device_id", session.request.DeviceID,
"caller", call.public.Number,
"call_id", call.public.ID,
)
}
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
calledNumber := identityNumber(request.value("To"))
if calledNumber == "" {
@@ -487,8 +501,10 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon
"To: "+to,
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d ACK", call.cseq),
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
)
lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
session.writeMu.Lock()
@@ -565,8 +581,10 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
lines = append(lines,
"From: "+from, "To: "+to, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(),
"User-Agent: "+session.callUserAgent(),
)
lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "",
)
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
@@ -697,10 +715,10 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
"Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d %s", cseq, method),
"Supported: 100rel, timer",
"User-Agent: "+session.callUserAgent(),
"User-Agent: "+session.imsUserAgent(),
)
if method != "CANCEL" {
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo())
lines = session.appendPAccessNetworkInfoHeader(lines)
}
if method == "UPDATE" {
lines = append(lines, session.dialogContactHeader())
@@ -759,6 +777,13 @@ func (session *Session) dialogContactHeader() string {
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
return ""
}
if session.imsRegisterOptions().ContactFormat == vowifi.IMSContactFormatGSMA {
contact := "Contact: <sip:" + session.contactAddress() + `>;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
}
return contact
}
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
@@ -799,25 +824,20 @@ 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"
return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger())
}
func (session *Session) callUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
func (session *Session) appendPAccessNetworkInfoHeader(lines []string) []string {
if pani := session.pAccessNetworkInfo(); pani != "" {
return append(lines, "P-Access-Network-Info: "+pani)
}
return "vocat/1"
return lines
}
func callResponseDiagnostic(response *sipResponse) string {
@@ -843,6 +863,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
return
}
session.provider.config.Logger.Info("IMS call response",
"category", "call",
"device_id", session.request.DeviceID,
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
"sip_status", response.StatusCode,
"diagnostic", diagnostic,
+53 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -229,7 +231,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
"P-Preferred-Identity: <tel:+447700900123>\r\n",
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
"P-Access-Network-Info: 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",
} {
@@ -239,6 +241,56 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
}
}
func TestDialogRequestOmitsPAccessNetworkInfoWhenProfileDisablesPANI(t *testing.T) {
profileDir := t.TempDir()
profile := `{"version":1,"profiles":[{"id":"test-pani-disabled","match":{"home_plmns":["00101"]},"ims":{"pani_enabled":false}}]}`
if err := os.WriteFile(filepath.Join(profileDir, "pani-disabled.json"), []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
emptyProfileDir := t.TempDir()
t.Cleanup(func() {
if err := vowifi.LoadCarrierProfileDirectory(emptyProfileDir); err != nil {
t.Errorf("clear external carrier profiles: %v", err)
}
})
if err := vowifi.LoadCarrierProfileDirectory(profileDir); err != nil {
t.Fatal(err)
}
identity := vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01", IMSI: "001010123456789"}
pani := resolveSessionPAccessNetworkInfo(identity, nil)
if pani != "" {
t.Fatalf("disabled profile PANI = %q, want empty", pani)
}
client, peer := net.Pipe()
t.Cleanup(func() {
if err := client.Close(); err != nil {
t.Errorf("close client connection: %v", err)
}
})
t.Cleanup(func() {
if err := peer.Close(); err != nil {
t.Errorf("close peer connection: %v", err)
}
})
session := &Session{
request: vowifi.IMSRequest{Identity: identity},
transport: "tcp",
conn: client,
}
call := &imsCall{
target: "sip:[email protected]",
from: "<sip:[email protected]>;tag=local",
to: "<sip:[email protected]>;tag=remote",
callID: "pani-disabled-call",
}
request := string(session.buildDialogRequest(call, "BYE", 2))
if strings.Contains(request, "\r\nP-Access-Network-Info:") {
t.Fatalf("BYE contains disabled P-Access-Network-Info header:\n%s", request)
}
}
func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
session := &Session{
identity: identitySet{
+267 -96
View File
@@ -23,6 +23,7 @@ const (
defaultRegistrationExpiry = 3600 * time.Second
defaultTransactionTimeout = 12 * time.Second
maxAuthenticationChallenges = 3
defaultPANIWLANNode = "ffffffffffff"
)
var (
@@ -230,99 +231,109 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
if err != nil {
return nil, err
}
pcscf := provider.config.PCSCF
if pcscf == "" {
var pcscfCandidates []string
if provider.config.PCSCF != "" {
pcscfCandidates = []string{provider.config.PCSCF}
} else {
for _, candidate := range tunnel.PCSCF {
if strings.TrimSpace(candidate) != "" {
pcscf = candidate
break
candidate = strings.TrimSpace(candidate)
if candidate != "" {
pcscfCandidates = append(pcscfCandidates, candidate)
}
}
}
if pcscf == "" {
if len(pcscfCandidates) == 0 {
return nil, errors.New("ims: tunnel did not provide a P-CSCF")
}
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
if err != nil {
return nil, err
}
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
}
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
if cached := provider.cachedTransport(request.Identity); cached != "" {
transport = cached
carrierSelected = true
}
if transport == "" && !carrierSelected {
transport = transportHint
}
if transport == "" {
transport = provider.config.Transport
}
if transport == "" {
transport = "tcp"
}
localAddress := provider.config.LocalAddress
if localAddress == "" {
if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil {
localAddress = tunnel.LocalIPv6
} else {
localAddress = tunnel.LocalIPv4
if strings.TrimSpace(localAddress) == "" {
localAddress = tunnel.LocalIPv6
}
}
}
localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0])
if localAddress == "" {
return nil, errors.New("ims: tunnel did not provide a local address")
}
if !localAddressProvenByTunnel(localAddress, tunnel) {
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
}
transports := []string{transport}
if provider.config.AutoTransportFallback {
alternate := "udp"
if transport == "udp" {
alternate = "tcp"
}
transports = append(transports, alternate)
}
var lastErr error
for attempt, candidate := range transports {
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
if dialErr != nil {
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
if attempt+1 < len(transports) && ctx.Err() == nil {
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
continue
for pcscfIndex, pcscf := range pcscfCandidates {
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
if err != nil {
lastErr = err
continue
}
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
}
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
if cached := provider.cachedTransport(request.Identity); cached != "" {
transport = cached
carrierSelected = true
}
if transport == "" && !carrierSelected {
transport = transportHint
}
if transport == "" {
transport = provider.config.Transport
}
if transport == "" {
transport = "tcp"
}
localAddress := provider.config.LocalAddress
if localAddress == "" {
if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil {
localAddress = tunnel.LocalIPv6
} else {
localAddress = tunnel.LocalIPv4
if strings.TrimSpace(localAddress) == "" {
localAddress = tunnel.LocalIPv6
}
}
return nil, lastErr
}
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
if sessionErr != nil {
_ = connection.Close()
return nil, sessionErr
localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0])
if localAddress == "" {
return nil, errors.New("ims: tunnel did not provide a local address")
}
establishErr := session.establish(ctx)
if establishErr == nil {
provider.rememberTransport(request.Identity, candidate)
if attempt > 0 {
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
"transport", candidate)
if !localAddressProvenByTunnel(localAddress, tunnel) {
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
}
transports := []string{transport}
if provider.config.AutoTransportFallback {
alternate := "udp"
if transport == "udp" {
alternate = "tcp"
}
return session, nil
transports = append(transports, alternate)
}
sipResponseObserved := session.evidence.LastSIPCode != 0
session.abort()
lastErr = establishErr
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
return nil, lastErr
for attempt, candidate := range transports {
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
if dialErr != nil {
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
if attempt+1 < len(transports) && ctx.Err() == nil {
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
continue
}
break
}
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
if sessionErr != nil {
_ = connection.Close()
lastErr = sessionErr
break
}
establishErr := session.establish(ctx)
if establishErr == nil {
provider.rememberTransport(request.Identity, candidate)
if attempt > 0 || pcscfIndex > 0 {
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
"transport", candidate)
}
return session, nil
}
sipResponseObserved := session.evidence.LastSIPCode != 0
session.abort()
lastErr = establishErr
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
break
}
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
}
return nil, lastErr
}
@@ -384,8 +395,13 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
if !digitsBetween(imsi, 5, 16) {
return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid")
}
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimSpace(identity.HomeMNC)
profile := vowifi.ResolveCarrierProfile(identity)
mcc := strings.TrimSpace(profile.RouteMCC)
mnc := strings.TrimSpace(profile.RouteMNC)
if mcc == "" || mnc == "" {
mcc = strings.TrimSpace(identity.HomeMCC)
mnc = strings.TrimSpace(identity.HomeMNC)
}
if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) {
return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid")
}
@@ -395,7 +411,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
privateDomain := domain
publicDomain := domain
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
if profile.IMSIdentityProfile == vowifi.IMSProfileATT {
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
// the generic 3GPP PLMN IMS domain.
domain = "one.att.net"
@@ -579,6 +595,8 @@ type Session struct {
callID string
fromTag string
instanceID string
pani string
paniResolved bool
cseq uint32
auth *authenticationState
securityProposal securityProposal
@@ -631,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,
@@ -642,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,
@@ -950,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",
@@ -976,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 != "" {
@@ -1047,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 {
@@ -1059,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
@@ -1194,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",
@@ -1201,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++ {
+36 -5
View File
@@ -71,7 +71,16 @@ func (media *rtpMedia) ready() bool {
}
func (media *rtpMedia) offerSDP(local net.IP) []byte {
return media.buildSDP(local, "8 0", nil)
return media.buildSDP(local, "8 0 104 102 100", []string{
"a=rtpmap:8 PCMA/8000",
"a=rtpmap:0 PCMU/8000",
"a=rtpmap:104 AMR-WB/16000",
"a=fmtp:104 mode-change-capability=2;max-red=220",
"a=rtpmap:102 AMR/8000",
"a=fmtp:102 mode-change-capability=2;max-red=220",
"a=rtpmap:100 telephone-event/8000",
"a=fmtp:100 0-15",
})
}
func (media *rtpMedia) answerSDP(local net.IP) []byte {
@@ -81,8 +90,12 @@ func (media *rtpMedia) answerSDP(local net.IP) []byte {
if codec == "" {
return media.offerSDP(local)
}
rate := 8000
if codec == "AMR-WB" {
rate = 16000
}
return media.buildSDP(local, strconv.Itoa(int(payload)), []string{
fmt.Sprintf("a=rtpmap:%d %s/8000", payload, codec),
fmt.Sprintf("a=rtpmap:%d %s/%d", payload, codec, rate),
})
}
@@ -110,7 +123,16 @@ func (media *rtpMedia) buildSDP(local net.IP, formats string, attributes []strin
fmt.Sprintf("m=audio %d RTP/AVP %s", port, formats),
}
if attributes == nil {
lines = append(lines, "a=rtpmap:8 PCMA/8000", "a=rtpmap:0 PCMU/8000")
lines = append(lines,
"a=rtpmap:8 PCMA/8000",
"a=rtpmap:0 PCMU/8000",
"a=rtpmap:104 AMR-WB/16000",
"a=fmtp:104 mode-change-capability=2;max-red=220",
"a=rtpmap:102 AMR/8000",
"a=fmtp:102 mode-change-capability=2;max-red=220",
"a=rtpmap:100 telephone-event/8000",
"a=fmtp:100 0-15",
)
} else {
lines = append(lines, attributes...)
}
@@ -137,15 +159,24 @@ func (media *rtpMedia) configureRemote(body []byte) error {
name = "PCMU"
case 8:
name = "PCMA"
case 100:
continue
default:
name = fmt.Sprintf("PAYLOAD-%d", parsed)
}
}
if name == "PCMA" || name == "PCMU" {
if name != "TELEPHONE-EVENT" {
codec, payload = name, byte(parsed)
break
}
}
if codec == "" && len(formats) > 0 {
if parsed, parseErr := strconv.Atoi(formats[0]); parseErr == nil {
codec, payload = fmt.Sprintf("PAYLOAD-%d", parsed), byte(parsed)
}
}
if codec == "" {
return errors.New("ims: remote endpoint did not accept PCMA or PCMU audio")
return errors.New("ims: remote SDP has no usable audio format")
}
media.mu.Lock()
media.remote = &net.UDPAddr{IP: address, Port: port}
+7 -1
View File
@@ -911,7 +911,7 @@ func (session *Session) logInboundSMS(level slog.Level, message string, request
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger
}
base := []any{"device_id", session.request.DeviceID}
base := []any{"category", "sms", "subsystem", "ims", "device_id", session.request.DeviceID}
if request != nil {
base = append(base,
"call_id", strings.TrimSpace(request.value("Call-ID")),
@@ -1091,6 +1091,8 @@ func (session *Session) logOutboundSMS(level slog.Level, message string, attribu
}
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
base := []any{
"category", "sms",
"subsystem", "ims",
"device_id", session.request.DeviceID,
"home_plmn", plmn,
"transport", session.transport,
@@ -1166,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
}
+14 -14
View File
@@ -38,7 +38,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
DeviceID: "ec20",
ICCID: "89441000400128014257",
ICCID: "8944100000000000001",
ProfileName: "Vodafone UK",
UpstreamProxyID: "clash",
}); err != nil {
@@ -46,7 +46,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
}
route, err := (ProxyResolver{Store: database}).Resolve(
context.Background(),
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", HomeMNC: "15"},
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234", HomeMNC: "15"},
)
if err != nil {
t.Fatal(err)
@@ -67,7 +67,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
@@ -137,12 +137,12 @@ func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "Manual", UpstreamProxyID: "disabled",
}); err != nil {
t.Fatal(err)
}
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
})
if err == nil {
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
@@ -169,7 +169,7 @@ func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
t.Fatal(err)
}
request := vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
}
resolver := ProxyResolver{Store: database}
route, err := resolver.Resolve(ctx, request)
@@ -214,7 +214,7 @@ func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testin
t.Fatal(err)
}
}
iccid := "89441000400128014257"
iccid := "8944100000000000001"
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
}); err != nil {
@@ -257,12 +257,12 @@ func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
})
if err != nil {
t.Fatal(err)
@@ -400,10 +400,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
}
if err := projector.Save(context.Background(), vowifi.State{
DeviceID: "ec20",
ICCID: "89441000400128014257",
IMSI: "234159608751160",
ICCID: "8944100000000000001",
IMSI: "234150000000001",
Phase: vowifi.PhaseStopping,
PhoneNumber: "+447386083638",
PhoneNumber: "+447700900123",
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
UpdatedAt: time.Now().UTC(),
}); err != nil {
@@ -413,10 +413,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if runtime.ICCID != "89441000400128014257" || runtime.IMSI != "234159608751160" {
if runtime.ICCID != "8944100000000000001" || runtime.IMSI != "234150000000001" {
t.Fatalf("runtime identity = %q/%q", runtime.ICCID, runtime.IMSI)
}
if runtime.LocalPhone != "+447386083638" {
if runtime.LocalPhone != "+447700900123" {
t.Fatalf("runtime phone = %q", runtime.LocalPhone)
}
}
+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)
}
+222 -40
View File
@@ -1,55 +1,237 @@
import { useEffect, useState } from "react";
import { PlugConnectedRegular } from "@fluentui/react-icons";
import { useI18n, tf } from "../../lib/i18n";
import { cx } from "../../lib/utils";
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
type RateLevel = "green" | "yellow" | "orange" | "red";
function rateLevel(percent: number): RateLevel {
if (percent >= 100) return "green";
if (percent >= 80) return "yellow";
if (percent >= 50) return "orange";
return "red";
interface DayUptime {
dateKey: string; // YYYY-MM-DD
date: Date;
isToday: boolean;
daysAgo: number;
uptimePercent: number; // 0 - 100
status: "online" | "degraded" | "down" | "none";
}
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" },
yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" },
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
};
const STORAGE_KEY = "vocat_uptime_history_14d";
function get14DaysSlots(currentOnline: number, currentTotal: number): DayUptime[] {
let savedMap: Record<string, number> = {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) savedMap = JSON.parse(raw);
} catch {
/* ignore */
}
const now = new Date();
const slots: DayUptime[] = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const dateKey = `${y}-${m}-${day}`;
const isToday = i === 0;
let percent = 100;
if (isToday) {
if (currentTotal === 0) {
percent = -1;
} else {
percent = Math.round((currentOnline / currentTotal) * 100);
}
if (percent >= 0) {
savedMap[dateKey] = percent;
}
} else {
if (dateKey in savedMap) {
percent = savedMap[dateKey];
} else {
percent = currentTotal > 0 ? 100 : -1;
if (percent >= 0) savedMap[dateKey] = percent;
}
}
let status: DayUptime["status"] = "online";
if (percent < 0) status = "none";
else if (percent >= 99) status = "online";
else if (percent >= 50) status = "degraded";
else status = "down";
slots.push({
dateKey,
date: d,
isToday,
daysAgo: i,
uptimePercent: percent < 0 ? 0 : percent,
status,
});
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedMap));
} catch {
/* ignore */
}
return slots;
}
// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
const { t } = useI18n();
const percent = total > 0 ? Math.round((online / total) * 100) : null;
const level = percent === null ? null : rateLevel(percent);
const styles = level ? LEVEL_STYLES[level] : null;
const { t, lang } = useI18n();
const [hoveredDay, setHoveredDay] = useState<DayUptime | null>(null);
const [slots, setSlots] = useState<DayUptime[]>(() => get14DaysSlots(online, total));
useEffect(() => {
setSlots(get14DaysSlots(online, total));
}, [online, total]);
const currentPercent = total > 0 ? Math.round((online / total) * 100) : null;
const overallAvg =
slots.filter((s) => s.status !== "none").length > 0
? Math.round(
slots.filter((s) => s.status !== "none").reduce((acc, s) => acc + s.uptimePercent, 0) /
slots.filter((s) => s.status !== "none").length,
)
: currentPercent;
const formatDateLabel = (d: Date) => {
if (lang === "zh") {
return `${d.getMonth() + 1}${d.getDate()}`;
}
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
return (
<div className="ui-panel p-4">
<div className="mb-1 flex items-center gap-2">
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
</div>
<div className="flex items-center justify-center py-1">
{percent === null ? (
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</div>
) : (
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
{percent}
<span className="text-2xl">%</span>
<div className="ui-panel relative flex flex-col justify-between p-4 transition-all">
{/* Header */}
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
<PlugConnectedRegular className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
<span className="rounded px-1.5 py-0.2 text-[10px] font-semibold bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300">
14d
</span>
</div>
</div>
)}
</div>
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400">
{styles ? (
<span className="flex items-center gap-1">
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
{t(styles.labelKey)}
<div className="flex items-baseline gap-1">
{overallAvg === null ? (
<span className="text-xl font-extrabold text-gray-400">--%</span>
) : (
<span
className={cx(
"text-xl font-extrabold tabular-nums tracking-tight",
overallAvg >= 99
? "text-emerald-600 dark:text-emerald-400"
: overallAvg >= 80
? "text-yellow-600 dark:text-yellow-400"
: "text-red-600 dark:text-red-400",
)}
>
{overallAvg}%
</span>
)}
</div>
</div>
{/* Subtitle count */}
<div className="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<span
className={cx(
"inline-block h-1.5 w-1.5 rounded-full",
online > 0 ? "bg-emerald-500 animate-pulse" : "bg-gray-400",
)}
/>
<span className="tabular-nums font-medium">
{tf("{online}/{total} 台在线", { online, total })}
</span>
</div>
<span className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
{currentPercent !== null && currentPercent >= 99 ? t("运行优秀") : t("正常监控")}
</span>
) : null}
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
</div>
</div>
{/* Uptime Kuma 14-day Heartbeat Bars */}
<div className="my-2.5">
<div className="flex items-center gap-1 sm:gap-1.5 h-8 w-full">
{slots.map((slot) => {
let barBg = "bg-gray-200 dark:bg-white/10";
if (slot.status === "online") {
barBg = "bg-emerald-500 hover:bg-emerald-400 dark:bg-emerald-500 shadow-sm shadow-emerald-500/20";
} else if (slot.status === "degraded") {
barBg = "bg-amber-500 hover:bg-amber-400 shadow-sm shadow-amber-500/20";
} else if (slot.status === "down") {
barBg = "bg-rose-500 hover:bg-rose-400 shadow-sm shadow-rose-500/20";
}
return (
<div
key={slot.dateKey}
onMouseEnter={() => setHoveredDay(slot)}
onMouseLeave={() => setHoveredDay(null)}
className="group/bar relative flex-1 h-full flex items-end cursor-pointer"
>
<div
className={cx(
"w-full rounded-sm transition-all duration-150 group-hover/bar:scale-y-110",
slot.isToday ? "h-full ring-1 ring-emerald-400/40" : "h-full",
barBg,
)}
/>
{/* Floating Tooltip on Hover */}
{hoveredDay?.dateKey === slot.dateKey && (
<div className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-30 whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-[11px] font-medium text-white shadow-xl dark:bg-gray-800 border border-white/10">
<div className="font-bold flex items-center gap-1.5">
<span>{formatDateLabel(slot.date)}</span>
{slot.isToday ? (
<span className="rounded bg-emerald-500/30 px-1 text-[9px] text-emerald-300 font-normal">
{t("今天")}
</span>
) : slot.daysAgo === 1 ? (
<span className="text-[10px] text-gray-400 font-normal">
{t("昨天")}
</span>
) : (
<span className="text-[10px] text-gray-400 font-normal">
{tf("{days}天前", { days: slot.daysAgo })}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-gray-300">
<span>
{slot.status === "online"
? `🟢 ${slot.uptimePercent}% ${t("正常在线")}`
: slot.status === "degraded"
? `🟡 ${slot.uptimePercent}% ${t("部分离线")}`
: slot.status === "down"
? `🔴 0% ${t("完全离线")}`
: `${t("暂无数据")}`}
</span>
</div>
{/* Tooltip triangle */}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900 dark:border-t-gray-800" />
</div>
)}
</div>
);
})}
</div>
{/* Legend / Range labels */}
<div className="mt-1 flex items-center justify-between text-[10px] font-medium text-gray-400 dark:text-gray-500">
<span>{t("14天前")}</span>
<span className="opacity-75">{t("持续监测中")}</span>
<span>{t("今天")}</span>
</div>
</div>
</div>
);
+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"
+43
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",
@@ -1175,4 +1191,31 @@ export const EN_DICT: Record<string, string> = {
"绑定到该代理的国家规则将自动删除,相关国家会恢复直连。":
"Country rules bound to this proxy will be deleted, and those countries will revert to a direct connection.",
"{encoding} · 预计 {parts} 段 · {length} 字": "{encoding} · ~{parts} seg · {length} chars",
// Uptime & Monitoring translations
"运行优秀": "Excellent",
"正常监控": "Optimal",
"14天前": "14d ago",
"持续监测中": "Monitored",
"今天": "Today",
"昨天": "Yesterday",
"天前": "days ago",
"{days}天前": "{days}d ago",
"正常在线": "Online",
"部分离线": "Degraded",
"完全离线": "Offline",
// Additional missing system strings
"端口": "Port",
"错误详情": "Error Details",
"正在搜索网络": "Searching network",
"SM-DP+ 的公开 Profile 库存已耗尽,请稍后重试或更换服务。":
"The public profile inventory on the SM-DP+ is exhausted. Please try again later or use a different service.",
"此 SM-DP+ 的证书链不受当前 eUICC 信任;该卡不能使用此测试服务器。":
"The SM-DP+ certificate chain is not trusted by this eUICC; this card cannot use this test server.",
"激活码已被使用、已过期或被 SM-DP+ 拒绝,请更换新的 Matching ID。":
"The activation code has already been used, expired, or was rejected by SM-DP+. Please use a new Matching ID.",
"已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 ":
"Modem detected, but no AT serial port found: option driver may not recognize this PID or modem is in MBIM/RNDIS mode. You can ",
};
+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>
+121 -139
View File
@@ -314,7 +314,7 @@ export default function SmsPage() {
const selectContact = useCallback(
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
if (!key || (keyRef.current === key && messagesRef.current.length > 0)) return;
if (!key) return;
setKey(key);
if (syncRoute) syncQuery(deviceRef.current, key);
const thread = contactsRef.current.find((t) => t.key === key) || null;
@@ -338,8 +338,8 @@ export default function SmsPage() {
contactsList: SmsThread[],
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
) => {
const { syncRoute = false, silent = false, scrollToBottom = false } = opts;
const active = contactsList.find((t) => t.key === keyRef.current) || null;
const { silent = false, scrollToBottom = false } = opts;
const active = (keyRef.current && contactsList.find((t) => t.key === keyRef.current)) || null;
if (active) {
const ok = await loadThreadFor(active, device, silent);
if (ok) {
@@ -350,16 +350,8 @@ export default function SmsPage() {
}
setMessagesState([]);
setHasMoreState(false);
if (keyRef.current) {
setKey("");
if (syncRoute) syncQuery(device, "");
}
const filtered = filterThreads(contactsList, searchRef.current);
if (!isMobileRef.current && filtered.length > 0) {
await selectContact(filtered[0].key, { syncRoute, silent, scrollToBottom });
}
},
[loadThreadFor, selectContact, syncQuery, scrollToBottomNow],
[loadThreadFor, scrollToBottomNow],
);
const clearSelection = useCallback(
@@ -479,7 +471,7 @@ export default function SmsPage() {
} finally {
setSending(false);
}
}, [composer, devices, refreshCurrent, scrollToBottomNow]);
}, [composer, devices, refreshCurrent, scrollToBottomNow, t]);
const openNewSms = useCallback(() => {
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
@@ -506,7 +498,7 @@ export default function SmsPage() {
setSending(false);
}
},
[refreshCurrent],
[refreshCurrent, t],
);
const deleteMessageAction = useCallback(
@@ -530,7 +522,7 @@ export default function SmsPage() {
setDeletingMessageId(null);
}
},
[deletingMessageId, refreshCurrent, clearSelection],
[deletingMessageId, refreshCurrent, clearSelection, t],
);
const deleteThreadAction = useCallback(
@@ -560,7 +552,7 @@ export default function SmsPage() {
setDeletingThreadKey(null);
}
},
[deletingThreadKey, clearSelection, loadContacts],
[deletingThreadKey, clearSelection, loadContacts, lang],
);
const closeActionSheet = useCallback(() => {
@@ -622,16 +614,6 @@ export default function SmsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prevIsMobile = useRef(isMobile);
useEffect(() => {
const was = prevIsMobile.current;
prevIsMobile.current = isMobile;
if (was && !isMobile && !keyRef.current) {
const filtered = filterThreads(contactsRef.current, searchRef.current);
if (filtered.length > 0) void selectContact(filtered[0].key, { syncRoute: true, scrollToBottom: false });
}
}, [isMobile, selectContact]);
useEffect(() => () => clearLongPress(), [clearLongPress]);
return (
@@ -670,121 +652,121 @@ export default function SmsPage() {
onRetry={refreshAll}
/>
) : null}
<div className="relative flex-1 overflow-hidden ui-card">
{contactsLoading && contacts.length === 0 ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
</div>
) : null}
<div className="sms-main-layout">
{isDesktop ? (
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
<div className="border-b border-gray-100 p-4 dark:border-white/10">
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
</div>
<div className="space-y-1 overflow-auto p-3">
{deviceFilters.map((d) => (
<button
key={d.id}
type="button"
onClick={() => void selectDevice(d.id)}
className={cx(
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
selectedDevice === d.id
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
)}
>
<div className="min-w-0">
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
<div className="relative flex-1 overflow-hidden ui-card">
{contactsLoading && contacts.length === 0 ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
</div>
{d.id !== "all" ? (
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
) : null}
<div className="sms-main-layout">
{isDesktop ? (
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
<div className="border-b border-gray-100 p-4 dark:border-white/10">
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
</div>
<div className="space-y-1 overflow-auto p-3">
{deviceFilters.map((d) => (
<button
key={d.id}
type="button"
onClick={() => void selectDevice(d.id)}
className={cx(
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
selectedDevice === d.id
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
)}
>
<div className="min-w-0">
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
</div>
{d.id !== "all" ? (
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
) : null}
</button>
))}
</div>
</div>
) : null}
</button>
))}
</div>
</div>
) : null}
{showContactColumn ? (
<ContactList
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
deviceOptions={deviceSelectOptions}
onSelectDevice={(id) => void selectDevice(id)}
searchQuery={searchQuery}
onSearchChange={setSearch}
loading={contactsLoading}
contacts={filteredContacts}
activeKey={selectedKey}
isUnread={isUnread}
deletingKey={deletingThreadKey}
canHover={canHover}
onSelect={(key) => void selectContact(key)}
onDelete={(t) => void deleteThreadAction(t)}
onRowPointerDown={onThreadPointerDown}
onRowPointerMove={moveLongPress}
onRowPointerEnd={clearLongPress}
/>
) : null}
{showDetailColumn ? (
<ThreadPanel
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
activeThread={activeThread}
canLoadMore={!!activeThread && hasMore}
loadingMore={loadingMore}
groups={groups}
deletingMessageId={deletingMessageId}
canHover={canHover}
composer={composer}
composerInfo={composerInfo}
composerLength={composerLength}
sending={sending}
detailRef={detailRef}
composerRef={composerRef}
onBack={onBack}
onScrollToBottom={scrollToBottomNow}
onLoadMore={() => void loadMore()}
onDeleteMessage={(m) => void deleteMessageAction(m)}
onComposerChange={setComposer}
onSend={() => void sendReply()}
onDetailScroll={onDetailScroll}
onMsgPointerDown={onMsgPointerDown}
onMsgPointerMove={moveLongPress}
onMsgPointerEnd={clearLongPress}
/>
) : null}
</div>
</div>
{actionSheetOpen && isMobile && actionTarget ? (
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
<div className="sms-action-sheet-title">{t("操作")}</div>
<Button
className="sms-danger-ghost-btn !w-full !justify-center"
icon={<DeleteRegular />}
onClick={() => void confirmSheetAction()}
>
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
</Button>
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
{t("取消")}
</Button>
</div>
</div>
) : null}
<NewSmsModal
open={newSmsOpen}
devices={devices}
defaultDeviceId={newSmsDevice}
sending={sending}
onClose={() => setNewSmsOpen(false)}
onSend={sendNewSms}
/>
{showContactColumn ? (
<ContactList
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
deviceOptions={deviceSelectOptions}
onSelectDevice={(id) => void selectDevice(id)}
searchQuery={searchQuery}
onSearchChange={setSearch}
loading={contactsLoading}
contacts={filteredContacts}
activeKey={selectedKey}
isUnread={isUnread}
deletingKey={deletingThreadKey}
canHover={canHover}
onSelect={(key) => void selectContact(key)}
onDelete={(t) => void deleteThreadAction(t)}
onRowPointerDown={onThreadPointerDown}
onRowPointerMove={moveLongPress}
onRowPointerEnd={clearLongPress}
/>
) : null}
{showDetailColumn ? (
<ThreadPanel
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
activeThread={activeThread}
canLoadMore={!!activeThread && hasMore}
loadingMore={loadingMore}
groups={groups}
deletingMessageId={deletingMessageId}
canHover={canHover}
composer={composer}
composerInfo={composerInfo}
composerLength={composerLength}
sending={sending}
detailRef={detailRef}
composerRef={composerRef}
onBack={onBack}
onScrollToBottom={scrollToBottomNow}
onLoadMore={() => void loadMore()}
onDeleteMessage={(m) => void deleteMessageAction(m)}
onComposerChange={setComposer}
onSend={() => void sendReply()}
onDetailScroll={onDetailScroll}
onMsgPointerDown={onMsgPointerDown}
onMsgPointerMove={moveLongPress}
onMsgPointerEnd={clearLongPress}
/>
) : null}
</div>
</div>
{actionSheetOpen && isMobile && actionTarget ? (
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
<div className="sms-action-sheet-title">{t("操作")}</div>
<Button
className="sms-danger-ghost-btn !w-full !justify-center"
icon={<DeleteRegular />}
onClick={() => void confirmSheetAction()}
>
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
</Button>
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
{t("取消")}
</Button>
</div>
</div>
) : null}
<NewSmsModal
open={newSmsOpen}
devices={devices}
defaultDeviceId={newSmsDevice}
sending={sending}
onClose={() => setNewSmsOpen(false)}
onSend={sendNewSms}
/>
</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 {
+3 -3
View File
@@ -18,7 +18,7 @@ const {
} = await import(moduleURL);
test("uses the current physical SIM when the device has no eSIM profiles", () => {
const iccid = "89441000400128014257";
const iccid = "8944100000000000001";
assert.deepEqual(buildAutomaticTaskProfileOptions([], iccid, "Current SIM"), [
{
@@ -30,7 +30,7 @@ test("uses the current physical SIM when the device has no eSIM profiles", () =>
});
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
const iccid = "89441000400128014257";
const iccid = "8944100000000000001";
assert.deepEqual(
buildAutomaticTaskProfileOptions(
@@ -49,7 +49,7 @@ test("does not duplicate the current SIM when it is already in the eSIM inventor
});
test("does not replace a saved profile when a failed inventory only exposes the current SIM", () => {
const currentICCID = "89441000400128014257";
const currentICCID = "8944100000000000001";
const savedICCID = "89104100000028106378";
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");