mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 07:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688e1e8311 | ||
|
|
f697c418a5 | ||
|
|
8d06231494 | ||
|
|
ee22576124 | ||
|
|
4f3f37ba7c | ||
|
|
53345d2915 | ||
|
|
0cba13634a | ||
|
|
b8df7f43f8 | ||
|
|
497cd24c8d |
+12
-6
@@ -41,7 +41,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
|
logs := loghub.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 2000)
|
||||||
logger := slog.New(logs)
|
logger := slog.New(logs)
|
||||||
|
|
||||||
args := os.Args[1:]
|
args := os.Args[1:]
|
||||||
@@ -206,7 +206,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cardReaders := pcsc.New()
|
cardReaders := pcsc.New()
|
||||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
|
deviceLogger := logger.With("category", "hardware")
|
||||||
|
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: deviceLogger})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create device manager: %w", err)
|
return fmt.Errorf("create device manager: %w", err)
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
}()
|
}()
|
||||||
pollContext, cancelPolling := context.WithCancel(context.Background())
|
pollContext, cancelPolling := context.WithCancel(context.Background())
|
||||||
defer cancelPolling()
|
defer cancelPolling()
|
||||||
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
go pollDeviceSnapshots(pollContext, deviceLogger, database, deviceManager)
|
||||||
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
||||||
go collectCellularTraffic(pollContext, logger, database)
|
go collectCellularTraffic(pollContext, logger, database)
|
||||||
go persistLogsToStore(pollContext, logger, logs, database)
|
go persistLogsToStore(pollContext, logger, logs, database)
|
||||||
@@ -640,7 +641,7 @@ func configureVoWiFiRuntime(
|
|||||||
Devices: mapper,
|
Devices: mapper,
|
||||||
}
|
}
|
||||||
manager := vowifiruntime.New(vowifiruntime.Options{
|
manager := vowifiruntime.New(vowifiruntime.Options{
|
||||||
Logger: logger,
|
Logger: logger.With("category", "vowifi"),
|
||||||
OnState: projector.Save,
|
OnState: projector.Save,
|
||||||
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
|
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
|
||||||
deviceConfig, err := database.Device(factoryContext, deviceID)
|
deviceConfig, err := database.Device(factoryContext, deviceID)
|
||||||
@@ -759,14 +760,15 @@ func newVoWiFiOrchestrator(
|
|||||||
if apn == "" {
|
if apn == "" {
|
||||||
apn = "ims"
|
apn = "ims"
|
||||||
}
|
}
|
||||||
|
vowifiLogger := logger.With("category", "vowifi", "device_id", deviceConfig.ID)
|
||||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
APN: apn, Logger: vowifiLogger, AutoProposalFallback: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||||
}
|
}
|
||||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||||
Logger: logger,
|
Logger: vowifiLogger,
|
||||||
// Carrier-specific transport and SMSC defaults live in the shared data
|
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||||
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||||
// alternate transport only if no SIP response was observed.
|
// alternate transport only if no SIP response was observed.
|
||||||
@@ -983,6 +985,10 @@ func persistLogsToStore(
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if loghub.IsHTTPAccessEntry(entry) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry = loghub.SanitizeEntry(entry)
|
||||||
var fields json.RawMessage
|
var fields json.RawMessage
|
||||||
if len(entry.Fields) > 0 {
|
if len(entry.Fields) > 0 {
|
||||||
if raw, err := json.Marshal(entry.Fields); err == nil {
|
if raw, err := json.Marshal(entry.Fields); err == nil {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/iniwex5/quectel-qmi-go v0.6.0
|
github.com/iniwex5/quectel-qmi-go v0.6.0
|
||||||
|
github.com/warthog618/sms v0.3.0
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.43.0
|
||||||
|
golang.org/x/text v0.41.0
|
||||||
howett.net/plist v1.0.1
|
howett.net/plist v1.0.1
|
||||||
modernc.org/sqlite v1.38.2
|
modernc.org/sqlite v1.38.2
|
||||||
)
|
)
|
||||||
@@ -21,7 +23,6 @@ require (
|
|||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/stretchr/testify v1.10.0 // 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
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/libc v1.66.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+28
-30
@@ -330,6 +330,18 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
|||||||
// operation self-healing without disturbing an active AKA exchange.
|
// operation self-healing without disturbing an active AKA exchange.
|
||||||
continue
|
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) {
|
if !isTransientEuiccCME(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1071,15 +1083,14 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
|||||||
manager.esimCacheMu.Unlock()
|
manager.esimCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// recoverAfterProfileSwitch owns the post-commit reset independently of the
|
// recoverAfterProfileSwitch owns the post-commit SIM reset independently of the
|
||||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
// initiating HTTP request.
|
||||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
|
||||||
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||||
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
|
if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native {
|
||||||
cancelReset()
|
cancelReset()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
time.Sleep(1500 * time.Millisecond)
|
time.Sleep(1 * time.Second)
|
||||||
}
|
}
|
||||||
// Native WWAN identity and profile verification are both QMI-backed.
|
// Native WWAN identity and profile verification are both QMI-backed.
|
||||||
// Do not enter the AT refresh path: OpenStick firmware can accept the
|
// Do not enter the AT refresh path: OpenStick firmware can accept the
|
||||||
@@ -1088,52 +1099,39 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
|||||||
}
|
}
|
||||||
cancelReset()
|
cancelReset()
|
||||||
if !manager.isPCSCDevice(id) {
|
if !manager.isPCSCDevice(id) {
|
||||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
_ = manager.softResetForProfileSwitch(resetContext, id)
|
||||||
cancelReset()
|
cancelReset()
|
||||||
}
|
}
|
||||||
manager.refreshAfterProfileSwitch(id)
|
manager.refreshAfterProfileSwitch(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshAfterProfileSwitch repopulates the device snapshot in the background
|
// refreshAfterProfileSwitch repopulates the device snapshot in the background
|
||||||
// after an eSIM profile switch + modem reboot. /overview only serves the cached
|
// after an eSIM profile switch.
|
||||||
// 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.
|
|
||||||
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||||
if manager.isPCSCDevice(id) {
|
if manager.isPCSCDevice(id) {
|
||||||
time.Sleep(750 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
for attempt := 0; attempt < 10; attempt++ {
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||||
_, _ = manager.Discover(ctx)
|
_, _ = manager.Discover(ctx)
|
||||||
_, err := manager.Refresh(ctx, id)
|
_, err := manager.Refresh(ctx, id)
|
||||||
cancel()
|
cancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(time.Second)
|
time.Sleep(500 * time.Millisecond)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const (
|
const (
|
||||||
settle = 8 * time.Second
|
settle = 1 * time.Second
|
||||||
interval = 4 * time.Second
|
interval = 1 * time.Second
|
||||||
attempts = 6
|
attempts = 5
|
||||||
)
|
)
|
||||||
time.Sleep(settle)
|
time.Sleep(settle)
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*2)
|
||||||
_, _ = manager.Discover(ctx)
|
_, err := manager.Refresh(ctx, id)
|
||||||
_, flightErr := manager.SetFlight(ctx, id, true)
|
|
||||||
var err error
|
|
||||||
if flightErr == nil {
|
|
||||||
_, err = manager.Refresh(ctx, id)
|
|
||||||
} else {
|
|
||||||
err = flightErr
|
|
||||||
}
|
|
||||||
cancel()
|
cancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
@@ -1233,7 +1231,7 @@ func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
|
|||||||
// is finalized by REFRESH/reset. The UI must not report success until the modem
|
// is finalized by REFRESH/reset. The UI must not report success until the modem
|
||||||
// is actually exposing the requested ICCID.
|
// is actually exposing the requested ICCID.
|
||||||
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
|
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(
|
func (manager *Manager) verifySwitchedICCIDAttempts(
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePendingNotifications(t *testing.T) {
|
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))
|
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}))
|
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
||||||
|
|
||||||
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
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.
|
// Results are grouped by receiver, then sorted by sequence number.
|
||||||
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
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)
|
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
||||||
}
|
}
|
||||||
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
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)
|
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ func esimTestProfile(t *testing.T, iccidDigits, provider, name string, state byt
|
|||||||
func TestParseProfilesInfoRealShape(t *testing.T) {
|
func TestParseProfilesInfoRealShape(t *testing.T) {
|
||||||
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
||||||
body := tlv([]byte{0xA0},
|
body := tlv([]byte{0xA0},
|
||||||
esimTestProfile(t, "89441000400128014257", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
esimTestProfile(t, "8944100000000000001", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
||||||
esimTestProfile(t, "89441000430011604140", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
esimTestProfile(t, "8944100000000000002", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
||||||
esimTestProfile(t, "89852351225001058508", "Webbing", "WEBBING", 0x00),
|
esimTestProfile(t, "8985200000000000001", "Webbing", "WEBBING", 0x00),
|
||||||
)
|
)
|
||||||
payload := tlv([]byte{0xBF, 0x2D}, body)
|
payload := tlv([]byte{0xBF, 0x2D}, body)
|
||||||
|
|
||||||
@@ -65,10 +65,10 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
|
|||||||
if len(profiles) != 3 {
|
if len(profiles) != 3 {
|
||||||
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
|
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])
|
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])
|
t.Fatalf("profile[1] = %#v", profiles[1])
|
||||||
}
|
}
|
||||||
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
|
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) {
|
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
||||||
real := esimTestProfile(t, "89441000400316048687", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
real := esimTestProfile(t, "8944100000000000003", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
||||||
duplicate := esimTestProfile(t, "89441000400316048687", "Duplicate", "Duplicate", 0x00)
|
duplicate := esimTestProfile(t, "8944100000000000003", "Duplicate", "Duplicate", 0x00)
|
||||||
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
||||||
empty := tlv([]byte{0xE3})
|
empty := tlv([]byte{0xE3})
|
||||||
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
|
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 {
|
if len(profiles) != 1 {
|
||||||
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
|
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])
|
t.Fatalf("profile = %#v", profiles[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestICCIDRoundTrip(t *testing.T) {
|
func TestICCIDRoundTrip(t *testing.T) {
|
||||||
for _, digits := range []string{"89441000400128014257", "8985235122500105850", "1"} {
|
for _, digits := range []string{"8944100000000000001", "8985200000000000001", "1"} {
|
||||||
bcd, err := encodeICCID(digits)
|
bcd, err := encodeICCID(digits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
||||||
@@ -110,7 +110,7 @@ func TestICCIDRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("round trip %q -> %q", digits, got)
|
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")
|
t.Fatal("21-digit ICCID was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,11 +126,11 @@ func TestEnableProfileRequestPads18DigitICCIDToTenOctets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
||||||
request, err := buildDeleteProfileRequest("89441000400128014257")
|
request, err := buildDeleteProfileRequest("89441000000000000001")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("DeleteProfile request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
||||||
@@ -144,28 +144,28 @@ func TestDeleteProfileRequestAndResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetNicknameRequestAndResult(t *testing.T) {
|
func TestSetNicknameRequestAndResult(t *testing.T) {
|
||||||
request, err := buildSetNicknameRequest("89441000400128014257", "Test")
|
request, err := buildSetNicknameRequest("89441000000000000001", "Test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("SetNickname request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
||||||
if !ok || result != 0 {
|
if !ok || result != 0 {
|
||||||
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
|
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)
|
t.Fatalf("long nickname error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDisableProfileRequestAndResult(t *testing.T) {
|
func TestDisableProfileRequestAndResult(t *testing.T) {
|
||||||
request, err := buildDisableProfileRequest("89441000400128014257")
|
request, err := buildDisableProfileRequest("89441000000000000001")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("DisableProfile request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
||||||
@@ -210,7 +210,7 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
|
|||||||
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
||||||
const target = "89492026266006792824"
|
const target = "89492026266006792824"
|
||||||
client := &transcriptClient{steps: []clientStep{
|
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")},
|
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
||||||
}}
|
}}
|
||||||
manager, id := newStartedTestManager(t, client)
|
manager, id := newStartedTestManager(t, client)
|
||||||
|
|||||||
+76
-13
@@ -206,6 +206,11 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
|||||||
}
|
}
|
||||||
seen := make(map[string]struct{}, len(candidates))
|
seen := make(map[string]struct{}, len(candidates))
|
||||||
|
|
||||||
|
type discoveryEvent struct {
|
||||||
|
connected bool
|
||||||
|
candidate modem.Candidate
|
||||||
|
}
|
||||||
|
events := make([]discoveryEvent, 0)
|
||||||
manager.mu.Lock()
|
manager.mu.Lock()
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if strings.TrimSpace(candidate.ID) == "" {
|
if strings.TrimSpace(candidate.ID) == "" {
|
||||||
@@ -218,8 +223,12 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
|||||||
candidate: candidate,
|
candidate: candidate,
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
|
events = append(events, discoveryEvent{connected: true, candidate: candidate})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !state.discovered {
|
||||||
|
events = append(events, discoveryEvent{connected: true, candidate: candidate})
|
||||||
|
}
|
||||||
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
|
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
|
||||||
state.resetClientOnLock = true
|
state.resetClientOnLock = true
|
||||||
}
|
}
|
||||||
@@ -231,10 +240,28 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
|||||||
if _, ok := seen[id]; ok {
|
if _, ok := seen[id]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if state.discovered {
|
||||||
|
events = append(events, discoveryEvent{candidate: state.candidate})
|
||||||
|
}
|
||||||
state.discovered = false
|
state.discovered = false
|
||||||
stale = append(stale, state)
|
stale = append(stale, state)
|
||||||
}
|
}
|
||||||
manager.mu.Unlock()
|
manager.mu.Unlock()
|
||||||
|
if manager.logger != nil {
|
||||||
|
for _, event := range events {
|
||||||
|
message := "hardware disconnected"
|
||||||
|
if event.connected {
|
||||||
|
message = "hardware connected"
|
||||||
|
}
|
||||||
|
manager.logger.Info(message,
|
||||||
|
"event", "hardware.discovery",
|
||||||
|
"device_id", event.candidate.ID,
|
||||||
|
"hardware_kind", event.candidate.HardwareKind,
|
||||||
|
"vendor_id", event.candidate.VendorID,
|
||||||
|
"product_id", event.candidate.ProductID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, state := range stale {
|
for _, state := range stale {
|
||||||
state.opMu.Lock()
|
state.opMu.Lock()
|
||||||
@@ -382,6 +409,11 @@ func (manager *Manager) setResult(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
previousError := state.lastError
|
previousError := state.lastError
|
||||||
|
var previousSnapshot *Snapshot
|
||||||
|
if state.snapshot != nil {
|
||||||
|
value := *state.snapshot
|
||||||
|
previousSnapshot = &value
|
||||||
|
}
|
||||||
if snapshot != nil {
|
if snapshot != nil {
|
||||||
value := *snapshot
|
value := *snapshot
|
||||||
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
||||||
@@ -394,6 +426,13 @@ func (manager *Manager) setResult(
|
|||||||
state.lastError = ""
|
state.lastError = ""
|
||||||
}
|
}
|
||||||
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
|
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
|
||||||
|
registrationChanged := snapshot != nil && manager.logger != nil &&
|
||||||
|
(previousSnapshot == nil ||
|
||||||
|
previousSnapshot.RegistrationStatus != snapshot.RegistrationStatus ||
|
||||||
|
previousSnapshot.OperatorCode != snapshot.OperatorCode ||
|
||||||
|
previousSnapshot.AccessTech != snapshot.AccessTech ||
|
||||||
|
previousSnapshot.PSAttached != snapshot.PSAttached ||
|
||||||
|
previousSnapshot.SIMStatus != snapshot.SIMStatus)
|
||||||
backend := state.backend
|
backend := state.backend
|
||||||
hardwareKind := state.candidate.HardwareKind
|
hardwareKind := state.candidate.HardwareKind
|
||||||
manager.mu.Unlock()
|
manager.mu.Unlock()
|
||||||
@@ -406,6 +445,21 @@ func (manager *Manager) setResult(
|
|||||||
"error", HardwareErrorDetail(err),
|
"error", HardwareErrorDetail(err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if registrationChanged {
|
||||||
|
manager.logger.Info(
|
||||||
|
"cellular registration state changed",
|
||||||
|
"category", "network",
|
||||||
|
"event", "network.registration",
|
||||||
|
"device_id", id,
|
||||||
|
"sim_status", snapshot.SIMStatus,
|
||||||
|
"registration_status", snapshot.RegistrationStatus,
|
||||||
|
"registration_source", snapshot.RegistrationSource,
|
||||||
|
"operator", snapshot.OperatorName,
|
||||||
|
"operator_code", snapshot.OperatorCode,
|
||||||
|
"access_technology", snapshot.AccessTech,
|
||||||
|
"packet_service_attached", snapshot.PSAttached,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
||||||
@@ -631,13 +685,11 @@ func (manager *Manager) Reboot(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebootForProfileSwitch is the post-EnableProfile modem reset. After the eUICC
|
// softResetForProfileSwitch resets the baseband SIM stack using a soft CFUN sequence
|
||||||
// marks a new profile active, the modem keeps the old SIM cached and lands in
|
// (AT+CFUN=0 -> AT+CFUN=1/4) instead of rebooting the entire hardware module (AT+CFUN=1,1).
|
||||||
// SIM failure (-CME 13) until it is bounced. ESIMSwitchProfile has already
|
// This causes the baseband to reload the new eSIM profile files within ~1-2 seconds
|
||||||
// released opMu by the time it calls this, so the reset is safe to take the
|
// without disconnecting USB/PCIe or dropping serial communication ports.
|
||||||
// lock. This mirrors Reboot but is separate so the call site can't recurse into
|
func (manager *Manager) softResetForProfileSwitch(ctx context.Context, id string) error {
|
||||||
// a guarded-reset path.
|
|
||||||
func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) error {
|
|
||||||
state, err := manager.lookup(id)
|
state, err := manager.lookup(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -652,14 +704,25 @@ func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) e
|
|||||||
manager.setResult(id, state, nil, err)
|
manager.setResult(id, state, nil, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
|
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, err = client.Execute(commandCtx, "AT+CFUN=1,1")
|
|
||||||
if closeErr := client.Close(); err == nil {
|
// 1. Cycle SIM interface to minimum functionality / clear cached SIM files
|
||||||
err = closeErr
|
_, _ = 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.clearSnapshot(id, state)
|
||||||
manager.setResult(id, state, nil, err)
|
manager.setResult(id, state, nil, err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
|||||||
wantPLMN string
|
wantPLMN string
|
||||||
wantCountry string
|
wantCountry string
|
||||||
}{
|
}{
|
||||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
{imsi: "234330000000001", wantPLMN: "23433", wantCountry: "GB"},
|
||||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
{imsi: "234150000000001", wantPLMN: "23415", wantCountry: "GB"},
|
||||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
{imsi: "234870000000001", wantPLMN: "23487", wantCountry: "GB"},
|
||||||
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
|
{imsi: "454000000000001", wantPLMN: "45400", wantCountry: "HK"},
|
||||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
{imsi: "310260000000001", wantPLMN: "310260", wantCountry: "US"},
|
||||||
}
|
}
|
||||||
for _, item := range tests {
|
for _, item := range tests {
|
||||||
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
||||||
|
|||||||
+246
-24
@@ -8,7 +8,13 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf16"
|
"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{
|
var gsm7DefaultAlphabet = [128]rune{
|
||||||
@@ -770,14 +776,19 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
|||||||
var septetCount int
|
var septetCount int
|
||||||
if toa&0x70 == 0x50 {
|
if toa&0x70 == 0x50 {
|
||||||
// 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field
|
// 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).
|
// is a count of useful semi-octets, not a character count. In particular,
|
||||||
// The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2.
|
// a three-character sender such as "OKX" has length 6. Treating every
|
||||||
// However, some non-standard sources specify length as the direct count of septets
|
// short length as a septet count consumes PID/DCS bytes as part of the
|
||||||
// (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes).
|
// address and shifts the entire TPDU, producing plausible-looking GSM-7
|
||||||
if length >= 7 {
|
// garbage instead of the message body.
|
||||||
byteCount = (int(length) + 1) / 2
|
byteCount = (int(length) + 1) / 2
|
||||||
septetCount = int(length) * 4 / 7
|
septetCount = int(length) * 4 / 7
|
||||||
} else {
|
|
||||||
|
// 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
|
byteCount = (int(length)*7 + 7) / 8
|
||||||
septetCount = int(length)
|
septetCount = int(length)
|
||||||
}
|
}
|
||||||
@@ -798,6 +809,18 @@ func readTPAddress(cursor *pduCursor) (string, error) {
|
|||||||
return decodeNumericAddress(value, int(length), toa), nil
|
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 {
|
func decodeNumericAddress(value []byte, digits int, toa byte) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
if toa&0x70 == 0x10 {
|
if toa&0x70 == 0x10 {
|
||||||
@@ -825,9 +848,9 @@ func decodeUserData(
|
|||||||
udl int,
|
udl int,
|
||||||
message *SMSMessage,
|
message *SMSMessage,
|
||||||
) error {
|
) error {
|
||||||
alphabet := dcs & 0x0c
|
alphabet := decodeSMSAlphabet(dcs)
|
||||||
expectedBytes := udl
|
expectedBytes := udl
|
||||||
if alphabet == 0 {
|
if alphabet == smsAlphabetGSM7 {
|
||||||
expectedBytes = (udl*7 + 7) / 8
|
expectedBytes = (udl*7 + 7) / 8
|
||||||
}
|
}
|
||||||
if expectedBytes > len(data) {
|
if expectedBytes > len(data) {
|
||||||
@@ -848,8 +871,12 @@ func decodeUserData(
|
|||||||
message.Concat = parseConcatHeader(data[1:headerBytes])
|
message.Concat = parseConcatHeader(data[1:headerBytes])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var header []byte
|
||||||
|
if headerBytes > 0 {
|
||||||
|
header = data[1:headerBytes]
|
||||||
|
}
|
||||||
switch alphabet {
|
switch alphabet {
|
||||||
case 0:
|
case smsAlphabetGSM7:
|
||||||
message.Encoding = SMSEncodingGSM7PDU
|
message.Encoding = SMSEncodingGSM7PDU
|
||||||
headerSeptets := 0
|
headerSeptets := 0
|
||||||
if headerBytes > 0 {
|
if headerBytes > 0 {
|
||||||
@@ -860,33 +887,228 @@ func decodeUserData(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
text, err := decodeGSM7(septets)
|
text, err := decodeGSM7WithHeader(septets, header)
|
||||||
message.Text = text
|
message.Text = text
|
||||||
return err
|
return err
|
||||||
case 8:
|
case smsAlphabetUCS2:
|
||||||
message.Encoding = SMSEncodingUCS2PDU
|
message.Encoding = SMSEncodingUCS2PDU
|
||||||
payload := data[headerBytes:]
|
payload := data[headerBytes:]
|
||||||
if len(payload)%2 != 0 {
|
text, ok := decodeUTF16Bytes(payload)
|
||||||
return errors.New("UCS2 SMS has an odd byte count")
|
if ok {
|
||||||
}
|
message.Text = text
|
||||||
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]))
|
|
||||||
}
|
|
||||||
message.Text = string(utf16.Decode(units))
|
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
return errors.New("UCS2 SMS has invalid UTF-16 data")
|
||||||
default:
|
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:]
|
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))
|
message.Text = strings.ToUpper(hex.EncodeToString(payload))
|
||||||
return nil
|
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 {
|
func parseConcatHeader(header []byte) *SMSConcatInfo {
|
||||||
for index := 0; index+1 < len(header); {
|
for index := 0; index+1 < len(header); {
|
||||||
identifier := header[index]
|
identifier := header[index]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package device
|
package device
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestDecode8BitPDUShowsHexPayload(t *testing.T) {
|
||||||
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
// SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data,
|
||||||
// alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC.
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,32 @@ func (manager *Manager) readSnapshot(
|
|||||||
if response, ok := optional("AT+CPIN?"); ok {
|
if response, ok := optional("AT+CPIN?"); ok {
|
||||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
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")
|
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||||
if ccidErr != nil {
|
if ccidErr != nil {
|
||||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
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)
|
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
previousICCID = strings.TrimSpace(previousICCID)
|
|
||||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||||
// A different physical SIM must never inherit the previous card's
|
// A different physical SIM must never inherit the previous card's
|
||||||
// permission to use cellular RF. Disable RF before reading serving-cell
|
// permission to use cellular RF. Disable RF before reading serving-cell
|
||||||
// or operator state; policy reconciliation will then start VoWiFi.
|
// or operator state; policy reconciliation will then start VoWiFi.
|
||||||
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
|
_, _ = manager.command(ctx, client, "AT+CFUN=4")
|
||||||
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
|
|
||||||
}
|
|
||||||
snapshot.SIMChanged = true
|
snapshot.SIMChanged = true
|
||||||
}
|
}
|
||||||
if response, ok := optional("AT+CIMI"); ok {
|
if response, ok := optional("AT+CIMI"); ok {
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ const (
|
|||||||
SMSEncodingGSM7Text SMSEncoding = "gsm7_text"
|
SMSEncodingGSM7Text SMSEncoding = "gsm7_text"
|
||||||
SMSEncodingGSM7PDU SMSEncoding = "gsm7_pdu"
|
SMSEncodingGSM7PDU SMSEncoding = "gsm7_pdu"
|
||||||
SMSEncodingUCS2PDU SMSEncoding = "ucs2_pdu"
|
SMSEncodingUCS2PDU SMSEncoding = "ucs2_pdu"
|
||||||
|
SMSEncodingUTF8PDU SMSEncoding = "utf8_pdu"
|
||||||
|
SMSEncodingGB18030 SMSEncoding = "gb18030_pdu"
|
||||||
|
SMSEncodingLatin1 SMSEncoding = "latin1_pdu"
|
||||||
SMSEncoding8BitPDU SMSEncoding = "8bit_pdu"
|
SMSEncoding8BitPDU SMSEncoding = "8bit_pdu"
|
||||||
SMSEncodingUnknown SMSEncoding = "unknown"
|
SMSEncodingUnknown SMSEncoding = "unknown"
|
||||||
)
|
)
|
||||||
|
|||||||
+23
-3
@@ -9,9 +9,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entry is the stable, secret-neutral representation exposed by the log API.
|
// Entry is the stable, centrally-redacted representation exposed by the log
|
||||||
// Callers remain responsible for never adding credentials or keying material
|
// API. The Hub sanitizes both the downstream handler and the captured entry so
|
||||||
// to slog attributes.
|
// diagnostic logs can be safely exported by users.
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
@@ -58,6 +58,7 @@ func (h *Hub) Enabled(ctx context.Context, level slog.Level) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
|
func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
|
||||||
|
record = sanitizeRecord(record)
|
||||||
err := h.next.Handle(ctx, record)
|
err := h.next.Handle(ctx, record)
|
||||||
fields := make(map[string]any)
|
fields := make(map[string]any)
|
||||||
for _, attr := range h.attrs {
|
for _, attr := range h.attrs {
|
||||||
@@ -81,6 +82,7 @@ func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
|
func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
attrs = sanitizeAttrs(attrs)
|
||||||
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
|
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
|
||||||
return &Hub{
|
return &Hub{
|
||||||
next: h.next.WithAttrs(attrs),
|
next: h.next.WithAttrs(attrs),
|
||||||
@@ -180,6 +182,24 @@ func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) {
|
|||||||
return channel, cancel
|
return channel, cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear drops captured history and every entry currently queued for live and
|
||||||
|
// persistence subscribers. Subscribers stay connected for future events.
|
||||||
|
func (h *Hub) Clear() {
|
||||||
|
h.core.mu.Lock()
|
||||||
|
h.core.entries = h.core.entries[:0]
|
||||||
|
for _, subscriber := range h.core.subscribers {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-subscriber:
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.core.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
|
func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
|
||||||
attr.Value = attr.Value.Resolve()
|
attr.Value = attr.Value.Resolve()
|
||||||
if attr.Equal(slog.Attr{}) {
|
if attr.Equal(slog.Attr{}) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package loghub
|
package loghub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -29,6 +32,51 @@ func TestHubHistoryFiltersAndBounds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHubRedactsDownstreamAndHistoryAndPreservesErrors(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
hub := New(slog.NewJSONHandler(&output, nil), 100)
|
||||||
|
logger := slog.New(hub).With("imsi", "234159611634973")
|
||||||
|
logger.Warn(
|
||||||
|
"delivery to +447700900123 failed",
|
||||||
|
"iccid", "8944101234567890123",
|
||||||
|
"peer", "+447700900456",
|
||||||
|
"error", errors.New("modem rejected MSISDN=447700900789 with +CMS ERROR: 305"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entry := hub.History(1, slog.LevelDebug, "")[0]
|
||||||
|
if strings.Contains(entry.Message, "447700900123") {
|
||||||
|
t.Fatalf("message was not redacted: %q", entry.Message)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"imsi", "iccid", "peer"} {
|
||||||
|
if value := entry.Fields[key]; !strings.Contains(value.(string), "REDACTED") {
|
||||||
|
t.Fatalf("%s = %#v, want redacted", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errorText, ok := entry.Fields["error"].(string)
|
||||||
|
if !ok || !strings.Contains(errorText, "+CMS ERROR: 305") || strings.Contains(errorText, "447700900789") {
|
||||||
|
t.Fatalf("error = %#v, want original modem error with identity redacted", entry.Fields["error"])
|
||||||
|
}
|
||||||
|
if raw := output.String(); strings.Contains(raw, "234159611634973") || strings.Contains(raw, "447700900") {
|
||||||
|
t.Fatalf("downstream output leaked an identity: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeEntryProtectsLegacyNestedFields(t *testing.T) {
|
||||||
|
entry := SanitizeEntry(Entry{
|
||||||
|
Message: "incoming SIP from sip:[email protected]",
|
||||||
|
Fields: map[string]any{
|
||||||
|
"details": map[string]any{"associated_number": "+447700900456", "status": "registered"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if strings.Contains(entry.Message, "447700900123") {
|
||||||
|
t.Fatalf("message = %q", entry.Message)
|
||||||
|
}
|
||||||
|
details := entry.Fields["details"].(map[string]any)
|
||||||
|
if strings.Contains(details["associated_number"].(string), "447700900456") {
|
||||||
|
t.Fatalf("nested field leaked: %#v", details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHubSubscription(t *testing.T) {
|
func TestHubSubscription(t *testing.T) {
|
||||||
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
|
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||||
entries, cancel := hub.Subscribe(1)
|
entries, cancel := hub.Subscribe(1)
|
||||||
@@ -47,3 +95,29 @@ func TestHubSubscription(t *testing.T) {
|
|||||||
t.Fatal("timed out waiting for log entry")
|
t.Fatal("timed out waiting for log entry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHubClearDropsHistoryAndQueuedEntries(t *testing.T) {
|
||||||
|
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||||
|
entries, cancel := hub.Subscribe(4)
|
||||||
|
defer cancel()
|
||||||
|
logger := slog.New(hub)
|
||||||
|
logger.Info("before clear")
|
||||||
|
hub.Clear()
|
||||||
|
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||||
|
t.Fatalf("history after Clear = %#v", history)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case entry := <-entries:
|
||||||
|
t.Fatalf("queued entry survived Clear: %#v", entry)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
logger.Info("after clear")
|
||||||
|
select {
|
||||||
|
case entry := <-entries:
|
||||||
|
if entry.Message != "after clear" {
|
||||||
|
t.Fatalf("entry = %#v", entry)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("subscriber did not remain active after Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package loghub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sipIdentityPattern = regexp.MustCompile(`(?i)\b(sips?|tel):([^@;>,\s]+)(@[^;>,\s]+)?`)
|
||||||
|
internationalPhonePattern = regexp.MustCompile(`(?:\+|00)[0-9][0-9 ()-]{5,}[0-9]`)
|
||||||
|
longDigitsPattern = regexp.MustCompile(`\b[0-9]{7,22}\b`)
|
||||||
|
labeledIdentityPattern = regexp.MustCompile(`(?i)\b(iccid|imsi|msisdn|imei|eid)\s*([=:])\s*([a-z0-9+_-]{7,})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsHTTPAccessEntry identifies legacy request-traffic entries. Access traffic
|
||||||
|
// is intentionally excluded from the user diagnostic log surface.
|
||||||
|
func IsHTTPAccessEntry(entry Entry) bool {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(entry.Message), "http request") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
category, _ := entry.Fields["category"].(string)
|
||||||
|
return strings.EqualFold(strings.TrimSpace(category), "http_access")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeEntry also protects records that were persisted by an older build
|
||||||
|
// before central redaction was introduced.
|
||||||
|
func SanitizeEntry(entry Entry) Entry {
|
||||||
|
entry.Message = RedactString(entry.Message)
|
||||||
|
entry.Caller = RedactString(entry.Caller)
|
||||||
|
if entry.Fields != nil {
|
||||||
|
entry.Fields = sanitizeMap(entry.Fields)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactString masks common telecom identities while retaining enough of the
|
||||||
|
// suffix to correlate repeated events in an exported diagnostic log.
|
||||||
|
func RedactString(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
value = labeledIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||||
|
parts := labeledIdentityPattern.FindStringSubmatch(match)
|
||||||
|
return parts[1] + parts[2] + maskToken(parts[3])
|
||||||
|
})
|
||||||
|
value = sipIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||||
|
parts := sipIdentityPattern.FindStringSubmatch(match)
|
||||||
|
domain := parts[3]
|
||||||
|
return parts[1] + ":" + maskToken(parts[2]) + domain
|
||||||
|
})
|
||||||
|
value = internationalPhonePattern.ReplaceAllStringFunc(value, maskToken)
|
||||||
|
return longDigitsPattern.ReplaceAllStringFunc(value, maskToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeRecord(record slog.Record) slog.Record {
|
||||||
|
clean := slog.NewRecord(record.Time, record.Level, RedactString(record.Message), record.PC)
|
||||||
|
record.Attrs(func(attr slog.Attr) bool {
|
||||||
|
clean.AddAttrs(sanitizeAttr(attr))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAttrs(attrs []slog.Attr) []slog.Attr {
|
||||||
|
clean := make([]slog.Attr, 0, len(attrs))
|
||||||
|
for _, attr := range attrs {
|
||||||
|
clean = append(clean, sanitizeAttr(attr))
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAttr(attr slog.Attr) slog.Attr {
|
||||||
|
attr.Value = attr.Value.Resolve()
|
||||||
|
if attr.Equal(slog.Attr{}) {
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
if sensitiveKey(attr.Key) {
|
||||||
|
return slog.String(attr.Key, maskToken(valueText(attr.Value.Any())))
|
||||||
|
}
|
||||||
|
if attr.Value.Kind() == slog.KindGroup {
|
||||||
|
children := attr.Value.Group()
|
||||||
|
return slog.Group(attr.Key, attrsToAny(sanitizeAttrs(children))...)
|
||||||
|
}
|
||||||
|
switch attr.Value.Kind() {
|
||||||
|
case slog.KindString:
|
||||||
|
return slog.String(attr.Key, RedactString(attr.Value.String()))
|
||||||
|
case slog.KindAny:
|
||||||
|
return slog.Any(attr.Key, sanitizeAny(attr.Value.Any(), attr.Key))
|
||||||
|
default:
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attrsToAny(attrs []slog.Attr) []any {
|
||||||
|
values := make([]any, len(attrs))
|
||||||
|
for index := range attrs {
|
||||||
|
values[index] = attrs[index]
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAny(value any, key string) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sensitiveKey(key) {
|
||||||
|
return maskToken(valueText(value))
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case error:
|
||||||
|
return RedactString(typed.Error())
|
||||||
|
case string:
|
||||||
|
return RedactString(typed)
|
||||||
|
case []byte:
|
||||||
|
return RedactString(string(typed))
|
||||||
|
case json.RawMessage:
|
||||||
|
var decoded any
|
||||||
|
if json.Unmarshal(typed, &decoded) == nil {
|
||||||
|
return sanitizeAny(decoded, key)
|
||||||
|
}
|
||||||
|
return RedactString(string(typed))
|
||||||
|
case map[string]any:
|
||||||
|
return sanitizeMap(typed)
|
||||||
|
case []any:
|
||||||
|
result := make([]any, len(typed))
|
||||||
|
for index := range typed {
|
||||||
|
result[index] = sanitizeAny(typed[index], key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case time.Time, time.Duration:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
rv := reflect.ValueOf(value)
|
||||||
|
if rv.IsValid() && (rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct || rv.Kind() == reflect.Pointer) {
|
||||||
|
if raw, err := json.Marshal(value); err == nil {
|
||||||
|
var decoded any
|
||||||
|
if json.Unmarshal(raw, &decoded) == nil {
|
||||||
|
return sanitizeAny(decoded, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stringer, ok := value.(fmt.Stringer); ok {
|
||||||
|
return RedactString(stringer.String())
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeMap(source map[string]any) map[string]any {
|
||||||
|
result := make(map[string]any, len(source))
|
||||||
|
for key, value := range source {
|
||||||
|
result[key] = sanitizeAny(value, key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sensitiveKey(key string) bool {
|
||||||
|
normalized := strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
return unicode.ToLower(r)
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, key)
|
||||||
|
if strings.Contains(normalized, "password") || strings.Contains(normalized, "passwd") ||
|
||||||
|
strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") ||
|
||||||
|
strings.Contains(normalized, "cookie") || strings.Contains(normalized, "authorization") ||
|
||||||
|
strings.Contains(normalized, "privateidentity") || strings.Contains(normalized, "publicidentity") ||
|
||||||
|
strings.Contains(normalized, "associatednumber") || strings.Contains(normalized, "sipuri") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch normalized {
|
||||||
|
case "iccid", "imsi", "imei", "eid", "supi", "suci", "msisdn", "phone", "phonenumber",
|
||||||
|
"number", "caller", "called", "callee", "recipient", "peer", "from", "to":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueText(value any) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err, ok := value.(error); ok {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskToken(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "[REDACTED]"
|
||||||
|
}
|
||||||
|
runes := []rune(value)
|
||||||
|
digits := make([]rune, 0, 4)
|
||||||
|
for index := len(runes) - 1; index >= 0 && len(digits) < 4; index-- {
|
||||||
|
if unicode.IsDigit(runes[index]) {
|
||||||
|
digits = append(digits, runes[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(digits) == 0 {
|
||||||
|
return "[REDACTED]"
|
||||||
|
}
|
||||||
|
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
|
||||||
|
digits[left], digits[right] = digits[right], digits[left]
|
||||||
|
}
|
||||||
|
return "[REDACTED:" + string(digits) + "]"
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,6 +22,20 @@ func (s *Server) recordAudit(
|
|||||||
outcome string,
|
outcome string,
|
||||||
remoteAddr string,
|
remoteAddr string,
|
||||||
) {
|
) {
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(outcome), "success") {
|
||||||
|
level = slog.LevelWarn
|
||||||
|
}
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Log(ctx, level, "user operation",
|
||||||
|
"category", auditLogCategory(action),
|
||||||
|
"event", action,
|
||||||
|
"actor", actor,
|
||||||
|
"entity_type", entityType,
|
||||||
|
"entity_id", entityID,
|
||||||
|
"outcome", outcome,
|
||||||
|
)
|
||||||
|
}
|
||||||
if s.store == nil {
|
if s.store == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -34,7 +49,24 @@ func (s *Server) recordAudit(
|
|||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("write audit event failed", "action", action, "error", err)
|
s.logger.Warn("write audit event failed", "category", "system", "action", action, "raw_error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditLogCategory(action string) string {
|
||||||
|
action = strings.ToLower(strings.TrimSpace(action))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(action, ".sms") || strings.HasPrefix(action, "sms."):
|
||||||
|
return "sms"
|
||||||
|
case strings.Contains(action, ".call") || strings.HasPrefix(action, "call."):
|
||||||
|
return "call"
|
||||||
|
case strings.Contains(action, "vowifi") || strings.Contains(action, "ims"):
|
||||||
|
return "vowifi"
|
||||||
|
case strings.Contains(action, "device") || strings.Contains(action, "esim") ||
|
||||||
|
strings.Contains(action, ".at.") || strings.Contains(action, ".ussd"):
|
||||||
|
return "hardware"
|
||||||
|
default:
|
||||||
|
return "operation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Warn("VoWiFi call operation failed",
|
||||||
|
"category", "call", "event", "call."+action,
|
||||||
|
"device_id", config.ID, "number", number, "call_id", callID,
|
||||||
|
"transport", transport, "raw_error", err,
|
||||||
|
)
|
||||||
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
|
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -156,6 +161,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
|
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
|
||||||
|
s.logger.Warn("cellular call operation rejected",
|
||||||
|
"category", "call", "event", "call."+action,
|
||||||
|
"device_id", config.ID, "number", number, "transport", transport,
|
||||||
|
"modem_final", response.Final, "raw_response", response.Text(),
|
||||||
|
)
|
||||||
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
|
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,11 +98,21 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
|
|||||||
if notification.Time.IsZero() {
|
if notification.Time.IsZero() {
|
||||||
notification.Time = time.Now().UTC()
|
notification.Time = time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info("incoming call detected",
|
||||||
|
"category", "call",
|
||||||
|
"event", "call.incoming",
|
||||||
|
"device_id", notification.DeviceID,
|
||||||
|
"caller", notification.Caller,
|
||||||
|
"called", notification.Called,
|
||||||
|
"transport", notification.Environment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
||||||
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
||||||
if s.logger != nil {
|
if s.logger != nil {
|
||||||
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
|
s.logger.Debug("suppressed duplicate incoming call notification", "category", "call", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,7 +147,7 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
|
|||||||
}
|
}
|
||||||
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
||||||
if s.logger != nil {
|
if s.logger != nil {
|
||||||
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
|
s.logger.Warn("send incoming call notification", "category", "call", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "raw_error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1002,6 +1002,11 @@ func (s *Server) handleVoWiFiReconnect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
|
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
|
||||||
|
s.logger.Warn("VoWiFi operation failed",
|
||||||
|
"category", "vowifi",
|
||||||
|
"event", "vowifi.operation_failed",
|
||||||
|
"raw_error", err,
|
||||||
|
)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, vowifiruntime.ErrNotRegistered):
|
case errors.Is(err, vowifiruntime.ErrNotRegistered):
|
||||||
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime")
|
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime")
|
||||||
@@ -1012,7 +1017,6 @@ func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
|
|||||||
case errors.Is(err, vowifi.ErrNotRunning):
|
case errors.Is(err, vowifi.ErrNotRunning):
|
||||||
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
|
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
|
||||||
default:
|
default:
|
||||||
s.logger.Warn("VoWiFi action rejected", "error", err)
|
|
||||||
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error())
|
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1074,13 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
|||||||
text += "\n"
|
text += "\n"
|
||||||
}
|
}
|
||||||
text += commandErr.Final
|
text += commandErr.Final
|
||||||
|
s.logger.Warn("AT command rejected by modem",
|
||||||
|
"category", "hardware",
|
||||||
|
"event", "hardware.at_rejected",
|
||||||
|
"device_id", id,
|
||||||
|
"modem_final", commandErr.Final,
|
||||||
|
"raw_response", text,
|
||||||
|
)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"response": text,
|
"response": text,
|
||||||
@@ -1509,6 +1520,11 @@ func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||||
|
s.logger.Warn("hardware operation failed",
|
||||||
|
"category", "hardware",
|
||||||
|
"event", "hardware.operation_failed",
|
||||||
|
"raw_error", device.HardwareErrorDetail(err),
|
||||||
|
)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, device.ErrNotFound):
|
case errors.Is(err, device.ErrNotFound):
|
||||||
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present")
|
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present")
|
||||||
@@ -1547,9 +1563,6 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
|||||||
case errors.Is(err, context.Canceled):
|
case errors.Is(err, context.Canceled):
|
||||||
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
||||||
default:
|
default:
|
||||||
// Preserve the hardware failure reason in the operator-visible log while
|
|
||||||
// keeping AT payloads and long APDU material out of it.
|
|
||||||
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
|
|
||||||
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
|
|||||||
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
||||||
SequenceNumber: 12,
|
SequenceNumber: 12,
|
||||||
Event: "delete",
|
Event: "delete",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
Address: "rsp.example.com",
|
Address: "rsp.example.com",
|
||||||
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
||||||
CanRetry: true,
|
CanRetry: true,
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
|||||||
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
||||||
DeviceID: "ec20_1",
|
DeviceID: "ec20_1",
|
||||||
Phase: "stopping",
|
Phase: "stopping",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
IMSI: "234159608751160",
|
IMSI: "234150000000001",
|
||||||
TunnelReady: true,
|
TunnelReady: true,
|
||||||
IMSReady: true,
|
IMSReady: true,
|
||||||
SMSReady: true,
|
SMSReady: true,
|
||||||
LocalPhone: "+447386083638",
|
LocalPhone: "+447700900123",
|
||||||
PhoneNumberSource: "ims_p_associated_uri",
|
PhoneNumberSource: "ims_p_associated_uri",
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -60,7 +60,7 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
|||||||
if got["vowifi_active"] != false {
|
if got["vowifi_active"] != false {
|
||||||
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
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)
|
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
|
||||||
}
|
}
|
||||||
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
||||||
@@ -169,7 +169,7 @@ func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, snapshot := range []*device.Snapshot{
|
for _, snapshot := range []*device.Snapshot{
|
||||||
{SIMStatus: "pin_required"},
|
{SIMStatus: "pin_required"},
|
||||||
{ICCID: "89441000400128014257"},
|
{ICCID: "8944100000000000001"},
|
||||||
{SIMReady: true},
|
{SIMReady: true},
|
||||||
} {
|
} {
|
||||||
if !snapshotHasSIM(snapshot) {
|
if !snapshotHasSIM(snapshot) {
|
||||||
|
|||||||
@@ -152,7 +152,24 @@ func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requireMethod(w, r, http.MethodGet) {
|
if r.Method == http.MethodDelete {
|
||||||
|
clearedAt := time.Now().UTC()
|
||||||
|
if s.logs != nil {
|
||||||
|
s.logs.Clear()
|
||||||
|
}
|
||||||
|
deleted, err := s.store.ClearLogEvents(r.Context(), clearedAt)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"data": map[string]any{"cleared": true, "deleted": deleted},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Allow", "GET, DELETE")
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
|
limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
|
||||||
@@ -170,7 +187,9 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
// backs the live stream).
|
// backs the live stream).
|
||||||
entries := []loghub.Entry{}
|
entries := []loghub.Entry{}
|
||||||
if s.store != nil {
|
if s.store != nil {
|
||||||
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit})
|
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{
|
||||||
|
Limit: limit, ExcludeMessage: "http request",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
return
|
||||||
@@ -179,11 +198,17 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
if storedLogLevel(event.Level) < minimum {
|
if storedLogLevel(event.Level) < minimum {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entry := storedLogToEntry(event)
|
entry := loghub.SanitizeEntry(storedLogToEntry(event))
|
||||||
|
if loghub.IsHTTPAccessEntry(entry) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if search != "" && !storedLogContains(entry, search) {
|
if search != "" && !storedLogContains(entry, search) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
|
if len(entries) == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// ListLogEvents is newest-first; present chronologically.
|
// ListLogEvents is newest-first; present chronologically.
|
||||||
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
||||||
@@ -283,7 +308,8 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if logLevel(entry.Level) < minimum {
|
entry = loghub.SanitizeEntry(entry)
|
||||||
|
if loghub.IsHTTPAccessEntry(entry) || logLevel(entry.Level) < minimum {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
|
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
|
||||||
@@ -308,7 +334,7 @@ func logLevel(value string) slog.Level {
|
|||||||
return slog.LevelError
|
return slog.LevelError
|
||||||
case "warn", "warning":
|
case "warn", "warning":
|
||||||
return slog.LevelWarn
|
return slog.LevelWarn
|
||||||
case "debug":
|
case "debug", "all", "":
|
||||||
return slog.LevelDebug
|
return slog.LevelDebug
|
||||||
default:
|
default:
|
||||||
return slog.LevelInfo
|
return slog.LevelInfo
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vocat/internal/loghub"
|
||||||
|
"vocat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleLogHistoryDeleteClearsMemoryAndDatabase(t *testing.T) {
|
||||||
|
server := newSettingsTestServer(t)
|
||||||
|
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||||
|
server.logs = hub
|
||||||
|
server.logger = slog.New(hub)
|
||||||
|
server.logger.Info("memory log")
|
||||||
|
if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{
|
||||||
|
Level: "info", Message: "persisted log",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil)
|
||||||
|
server.handleLogHistory(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||||
|
t.Fatalf("memory history after clear = %#v", history)
|
||||||
|
}
|
||||||
|
count, err := server.store.CountLogEvents(context.Background())
|
||||||
|
if err != nil || count != 0 {
|
||||||
|
t.Fatalf("persisted count after clear = %d, %v", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,13 +36,17 @@ func parseLoggingConfig(config loggingConfig) (loggingConfig, error) {
|
|||||||
if config.Count < 1 {
|
if config.Count < 1 {
|
||||||
config.Count = 10000
|
config.Count = 10000
|
||||||
}
|
}
|
||||||
|
if config.Count > store.MaxLogEvents {
|
||||||
|
config.Count = store.MaxLogEvents
|
||||||
|
}
|
||||||
if config.Days < 1 {
|
if config.Days < 1 {
|
||||||
config.Days = 30
|
config.Days = 30
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadLoggingConfig reads the persisted retention policy, defaulting to unlimited.
|
// loadLoggingConfig reads the persisted retention policy. "unlimited" means
|
||||||
|
// no user-selected limit below the global 10,000-row hard ceiling.
|
||||||
func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
|
func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
|
||||||
config := defaultLoggingConfig()
|
config := defaultLoggingConfig()
|
||||||
setting, err := s.store.AppSetting(ctx, loggingSettingKey)
|
setting, err := s.store.AppSetting(ctx, loggingSettingKey)
|
||||||
@@ -64,13 +68,17 @@ func (s *Server) applyLogRetention(ctx context.Context) error {
|
|||||||
switch config.Mode {
|
switch config.Mode {
|
||||||
case "days":
|
case "days":
|
||||||
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour)
|
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour)
|
||||||
_, err := s.store.PruneLogEvents(ctx, cutoff)
|
if _, err := s.store.PruneLogEvents(ctx, cutoff); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
|
||||||
return err
|
return err
|
||||||
case "count":
|
case "count":
|
||||||
_, err := s.store.PruneLogEventsToCount(ctx, config.Count)
|
_, err := s.store.PruneLogEventsToCount(ctx, config.Count)
|
||||||
return err
|
return err
|
||||||
default:
|
default:
|
||||||
return nil
|
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +124,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"count": config.Count,
|
"count": config.Count,
|
||||||
"days": config.Days,
|
"days": config.Days,
|
||||||
"stored_logs": stored,
|
"stored_logs": stored,
|
||||||
|
"max_logs": store.MaxLogEvents,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
@@ -152,6 +161,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"count": config.Count,
|
"count": config.Count,
|
||||||
"days": config.Days,
|
"days": config.Days,
|
||||||
"stored_logs": stored,
|
"stored_logs": stored,
|
||||||
|
"max_logs": store.MaxLogEvents,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testProfileICCID = "89441000400128014257"
|
const testProfileICCID = "8944100000000000001"
|
||||||
|
|
||||||
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -51,7 +51,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
|||||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||||
"upstream_proxy_id":"route-1",
|
"upstream_proxy_id":"route-1",
|
||||||
"bindings":[
|
"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"}
|
{"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)
|
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 {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
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) {
|
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
||||||
server, database, _ := newProfileBindingTestServer(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 {
|
if first.Code != http.StatusOK {
|
||||||
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
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 {
|
if second.Code != http.StatusConflict {
|
||||||
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,16 @@ func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoggingCountIsClampedToHardLimit(t *testing.T) {
|
||||||
|
config, err := parseLoggingConfig(loggingConfig{Mode: "count", Count: store.MaxLogEvents + 500})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if config.Count != store.MaxLogEvents {
|
||||||
|
t.Fatalf("count = %d, want %d", config.Count, store.MaxLogEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginLockoutViaHTTP(t *testing.T) {
|
func TestLoginLockoutViaHTTP(t *testing.T) {
|
||||||
app := newTestApplication(t)
|
app := newTestApplication(t)
|
||||||
for i := 0; i < 4; i++ {
|
for i := 0; i < 4; i++ {
|
||||||
|
|||||||
+47
-14
@@ -160,7 +160,7 @@ func New(options Options) (*Server, error) {
|
|||||||
mux.HandleFunc("/", server.handleSPA)
|
mux.HandleFunc("/", server.handleSPA)
|
||||||
|
|
||||||
server.handler = server.recoverPanics(
|
server.handler = server.recoverPanics(
|
||||||
server.securityHeaders(server.accessControl(server.logRequests(mux))),
|
server.securityHeaders(server.accessControl(server.logUserOperation(mux))),
|
||||||
)
|
)
|
||||||
return server, nil
|
return server, nil
|
||||||
}
|
}
|
||||||
@@ -557,16 +557,14 @@ func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusWriter struct {
|
type operationStatusWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
status int
|
status int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *statusWriter) Unwrap() http.ResponseWriter {
|
func (w *operationStatusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||||
return w.ResponseWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *statusWriter) WriteHeader(status int) {
|
func (w *operationStatusWriter) WriteHeader(status int) {
|
||||||
if w.status != 0 {
|
if w.status != 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -574,25 +572,60 @@ func (w *statusWriter) WriteHeader(status int) {
|
|||||||
w.ResponseWriter.WriteHeader(status)
|
w.ResponseWriter.WriteHeader(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
// logUserOperation records state-changing API actions, not request traffic.
|
||||||
|
// GET/HEAD polling, assets, health checks and the live log stream are never
|
||||||
|
// emitted, keeping the diagnostic page focused on actions a user initiated.
|
||||||
|
func (s *Server) logUserOperation(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
startedAt := time.Now()
|
if !strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||||
writer := &statusWriter{ResponseWriter: w}
|
r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions ||
|
||||||
|
strings.HasPrefix(r.URL.Path, "/api/auth/") || strings.HasPrefix(r.URL.Path, "/api/logs/") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer := &operationStatusWriter{ResponseWriter: w}
|
||||||
next.ServeHTTP(writer, r)
|
next.ServeHTTP(writer, r)
|
||||||
status := writer.status
|
status := writer.status
|
||||||
if status == 0 {
|
if status == 0 {
|
||||||
status = http.StatusOK
|
status = http.StatusOK
|
||||||
}
|
}
|
||||||
s.logger.Info(
|
level := slog.LevelInfo
|
||||||
"http request",
|
outcome := "success"
|
||||||
"method", r.Method,
|
message := "user operation completed"
|
||||||
"path", r.URL.Path,
|
if status >= http.StatusBadRequest {
|
||||||
|
level = slog.LevelWarn
|
||||||
|
outcome = "failed"
|
||||||
|
message = "user operation failed"
|
||||||
|
}
|
||||||
|
s.logger.Log(r.Context(), level, message,
|
||||||
|
"category", operationPathCategory(r.URL.Path),
|
||||||
|
"event", "user.operation",
|
||||||
|
"operation", strings.TrimPrefix(r.URL.Path, "/api/"),
|
||||||
|
"outcome", outcome,
|
||||||
"status", status,
|
"status", status,
|
||||||
"duration", time.Since(startedAt),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func operationPathCategory(path string) string {
|
||||||
|
path = strings.ToLower(path)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(path, "/sms"):
|
||||||
|
return "sms"
|
||||||
|
case strings.Contains(path, "/call"):
|
||||||
|
return "call"
|
||||||
|
case strings.Contains(path, "/vowifi") || strings.Contains(path, "/ims"):
|
||||||
|
return "vowifi"
|
||||||
|
case strings.Contains(path, "/network") || strings.Contains(path, "/operator"):
|
||||||
|
return "network"
|
||||||
|
case strings.Contains(path, "/device") || strings.Contains(path, "/esim") ||
|
||||||
|
strings.Contains(path, "/ussd") || strings.Contains(path, "/at"):
|
||||||
|
return "hardware"
|
||||||
|
default:
|
||||||
|
return "operation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -18,9 +19,36 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"vocat/internal/auth"
|
"vocat/internal/auth"
|
||||||
|
"vocat/internal/loghub"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestUserOperationLoggerExcludesReadTraffic(t *testing.T) {
|
||||||
|
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||||
|
server := &Server{logger: slog.New(hub)}
|
||||||
|
handler := server.logUserOperation(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/devices", nil))
|
||||||
|
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||||
|
t.Fatalf("GET traffic produced diagnostic logs: %#v", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPatch, "/api/devices/dev1/network", nil))
|
||||||
|
history := hub.History(10, slog.LevelDebug, "")
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("mutation log count = %d, want 1", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Message != "user operation completed" || history[0].Fields["category"] != "network" {
|
||||||
|
t.Fatalf("mutation log = %#v", history[0])
|
||||||
|
}
|
||||||
|
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil))
|
||||||
|
if history = hub.History(10, slog.LevelDebug, ""); len(history) != 1 {
|
||||||
|
t.Fatalf("log clear endpoint produced an operation log: %#v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type testApplication struct {
|
type testApplication struct {
|
||||||
server *httptest.Server
|
server *httptest.Server
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|||||||
@@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, message := range messages {
|
unreadIDs := make([]int64, 0, len(messages))
|
||||||
if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
|
for i := range messages {
|
||||||
message.Read = true
|
if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") {
|
||||||
_, _ = s.store.SaveSMSMessage(r.Context(), message)
|
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)
|
reverseSMS(messages)
|
||||||
@@ -363,16 +369,26 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
if sendErr != nil {
|
if sendErr != nil {
|
||||||
data["retry_safe"] = false
|
data["retry_safe"] = false
|
||||||
if result.PartsAccepted > 0 {
|
if result.PartsAccepted > 0 {
|
||||||
|
s.logger.Warn("multipart SMS was only partially accepted",
|
||||||
|
"category", "sms", "event", "sms.submission",
|
||||||
|
"device_id", request.DeviceID, "peer", request.Phone,
|
||||||
|
"transport", "cellular_at", "parts_attempted", result.PartsAttempted,
|
||||||
|
"parts_accepted", result.PartsAccepted, "raw_error", sendErr,
|
||||||
|
)
|
||||||
data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
|
data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Warn(
|
s.logger.Warn(
|
||||||
"SMS submission failed after modem interaction",
|
"SMS submission failed after modem interaction",
|
||||||
|
"category", "sms",
|
||||||
|
"event", "sms.submission",
|
||||||
"device_id", request.DeviceID,
|
"device_id", request.DeviceID,
|
||||||
|
"peer", request.Phone,
|
||||||
|
"transport", "cellular_at",
|
||||||
"parts_attempted", result.PartsAttempted,
|
"parts_attempted", result.PartsAttempted,
|
||||||
"parts_accepted", result.PartsAccepted,
|
"parts_accepted", result.PartsAccepted,
|
||||||
"error", sendErr,
|
"raw_error", sendErr,
|
||||||
)
|
)
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||||
"error": apiError{
|
"error": apiError{
|
||||||
@@ -384,6 +400,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !result.AllPartsAccepted {
|
if !result.AllPartsAccepted {
|
||||||
|
s.logger.Warn("SMS submission was not confirmed",
|
||||||
|
"category", "sms", "event", "sms.submission",
|
||||||
|
"device_id", request.DeviceID, "peer", request.Phone,
|
||||||
|
"transport", "cellular_at", "modem_final", result.ModemFinal,
|
||||||
|
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||||
|
)
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||||
"error": apiError{
|
"error": apiError{
|
||||||
Code: "sms_submission_unconfirmed",
|
Code: "sms_submission_unconfirmed",
|
||||||
@@ -393,6 +415,11 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.logger.Info("SMS submission accepted",
|
||||||
|
"category", "sms", "event", "sms.submission",
|
||||||
|
"device_id", request.DeviceID, "peer", request.Phone,
|
||||||
|
"transport", "cellular_at", "parts", result.PartsAccepted,
|
||||||
|
)
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +496,12 @@ func (s *Server) writeIMSSMSSendResult(
|
|||||||
"outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
|
"outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
|
||||||
}
|
}
|
||||||
if sendErr != nil {
|
if sendErr != nil {
|
||||||
|
s.logger.Warn("IMS SMS submission failed",
|
||||||
|
"category", "sms", "event", "sms.submission",
|
||||||
|
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||||
|
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||||
|
"raw_error", sendErr,
|
||||||
|
)
|
||||||
data["retry_safe"] = false
|
data["retry_safe"] = false
|
||||||
data["warning"] = sendErr.Error()
|
data["warning"] = sendErr.Error()
|
||||||
if result.PartsAccepted == 0 {
|
if result.PartsAccepted == 0 {
|
||||||
@@ -483,6 +516,11 @@ func (s *Server) writeIMSSMSSendResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !result.AllPartsAccepted && result.PartsAccepted == 0 {
|
if !result.AllPartsAccepted && result.PartsAccepted == 0 {
|
||||||
|
s.logger.Warn("IMS SMS submission was not confirmed",
|
||||||
|
"category", "sms", "event", "sms.submission",
|
||||||
|
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||||
|
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||||
|
)
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||||
"error": apiError{
|
"error": apiError{
|
||||||
Code: "ims_sms_submission_unconfirmed",
|
Code: "ims_sms_submission_unconfirmed",
|
||||||
@@ -492,6 +530,19 @@ func (s *Server) writeIMSSMSSendResult(
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if result.AllPartsAccepted {
|
||||||
|
s.logger.Info("IMS SMS submission accepted",
|
||||||
|
"category", "sms", "event", "sms.submission",
|
||||||
|
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||||
|
"parts", result.PartsAccepted,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
s.logger.Warn("multipart IMS SMS was only partially accepted",
|
||||||
|
"category", "sms", "event", "sms.submission",
|
||||||
|
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||||
|
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||||
|
)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,7 +698,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
|||||||
"delivery_status": message.DeliveryStatus,
|
"delivery_status": message.DeliveryStatus,
|
||||||
"data_coding_scheme": message.DataCodingScheme,
|
"data_coding_scheme": message.DataCodingScheme,
|
||||||
})
|
})
|
||||||
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
|
saved, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
DeviceID: config.ID,
|
DeviceID: config.ID,
|
||||||
ModemIMEI: modemIMEI,
|
ModemIMEI: modemIMEI,
|
||||||
@@ -664,7 +715,14 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
|||||||
Extra: extra,
|
Extra: extra,
|
||||||
})
|
})
|
||||||
if saveErr != nil {
|
if saveErr != nil {
|
||||||
s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr)
|
s.logger.Warn("persist modem SMS failed", "category", "sms", "device_id", config.ID, "raw_error", saveErr)
|
||||||
|
} else if saved.Direction == "inbound" && saved.CreatedAt.Unix() == saved.UpdatedAt.Unix() {
|
||||||
|
s.logger.Info("cellular SMS received",
|
||||||
|
"category", "sms", "event", "sms.received",
|
||||||
|
"device_id", config.ID, "peer", saved.Peer,
|
||||||
|
"transport", "cellular_at", "encoding", message.Encoding,
|
||||||
|
"parts", saved.PartsTotal,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Error("database operation failed", "error", err)
|
s.logger.Error("database operation failed", "category", "system", "event", "store.operation_failed", "raw_error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
|
writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,10 +236,10 @@ func (bot *telegramBot) getUpdates(
|
|||||||
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
||||||
if callback := update.CallbackQuery; callback != nil {
|
if callback := update.CallbackQuery; callback != nil {
|
||||||
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
|
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
|
return
|
||||||
}
|
}
|
||||||
_ = bot.answerCallback(ctx, config, callback.ID, "")
|
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "") }()
|
||||||
bot.handleCallback(ctx, config, callback)
|
bot.handleCallback(ctx, config, callback)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
|||||||
}
|
}
|
||||||
state := &vowifi.State{
|
state := &vowifi.State{
|
||||||
ICCID: "previous-card",
|
ICCID: "previous-card",
|
||||||
PhoneNumber: "+447386083638",
|
PhoneNumber: "+447700900123",
|
||||||
}
|
}
|
||||||
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
||||||
t.Fatalf("stale or placeholder number leaked as %q", got)
|
t.Fatalf("stale or placeholder number leaked as %q", got)
|
||||||
@@ -135,10 +135,10 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
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)
|
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)
|
t.Fatalf("profile brand overrode home carrier = %q", got)
|
||||||
}
|
}
|
||||||
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
|||||||
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
||||||
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
||||||
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
||||||
VALUES ('known', '89441000400128014257', 100);
|
VALUES ('known', '8944100000000000001', 100);
|
||||||
PRAGMA user_version = 11;
|
PRAGMA user_version = 11;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -138,7 +138,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
database := openTestStore(t, path)
|
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" {
|
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
||||||
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
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 {
|
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
||||||
t.Fatalf("thread should be read: %+v", contacts)
|
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")
|
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
|
||||||
if err != nil || deleted != 2 {
|
if err != nil || deleted != 2 {
|
||||||
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
|
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
|
||||||
@@ -789,11 +803,11 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
|||||||
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
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 {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
||||||
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
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) {
|
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
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)
|
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" {
|
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
|
||||||
t.Fatalf("log filter result = %+v, %v", logs, err)
|
t.Fatalf("log filter result = %+v, %v", logs, err)
|
||||||
}
|
}
|
||||||
|
if _, err := database.AppendLogEvent(ctx, LogEvent{
|
||||||
|
Time: recent, Level: "info", Message: " HTTP REQUEST ",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logs, err = database.ListLogEvents(ctx, LogFilter{Level: "info", ExcludeMessage: "http request"})
|
||||||
|
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
|
||||||
|
t.Fatalf("excluded log filter result = %+v, %v", logs, err)
|
||||||
|
}
|
||||||
auditDeleted, logDeleted, err := database.PruneEvents(
|
auditDeleted, logDeleted, err := database.PruneEvents(
|
||||||
ctx,
|
ctx,
|
||||||
old.Add(time.Minute),
|
old.Add(time.Minute),
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxLogEvents is the hard storage ceiling. Every new row beyond this limit
|
||||||
|
// replaces the oldest row regardless of the optional, stricter retention rule.
|
||||||
|
const MaxLogEvents = 10000
|
||||||
|
|
||||||
func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
|
func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
|
||||||
value.Action = strings.TrimSpace(value.Action)
|
value.Action = strings.TrimSpace(value.Action)
|
||||||
if value.Action == "" {
|
if value.Action == "" {
|
||||||
@@ -123,6 +127,8 @@ func auditEvent(row rowScanner) (AuditEvent, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) {
|
func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) {
|
||||||
|
s.logMu.Lock()
|
||||||
|
defer s.logMu.Unlock()
|
||||||
value.Level = strings.ToLower(strings.TrimSpace(value.Level))
|
value.Level = strings.ToLower(strings.TrimSpace(value.Level))
|
||||||
if value.Level == "" {
|
if value.Level == "" {
|
||||||
return LogEvent{}, errors.New("log level is required")
|
return LogEvent{}, errors.New("log level is required")
|
||||||
@@ -137,7 +143,18 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
|
|||||||
if value.Time.IsZero() {
|
if value.Time.IsZero() {
|
||||||
value.Time = time.Now().UTC()
|
value.Time = time.Now().UTC()
|
||||||
}
|
}
|
||||||
result, err := s.db.ExecContext(ctx, `
|
value.Fields = fields
|
||||||
|
if !s.logClearedAt.IsZero() && !value.Time.After(s.logClearedAt) {
|
||||||
|
// The entry was queued before a user cleared the log. Silently discard it
|
||||||
|
// so an in-flight persistence worker cannot resurrect cleared history.
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return LogEvent{}, fmt.Errorf("begin log append: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO log_events (event_time, level, message, caller, fields_json)
|
INSERT INTO log_events (event_time, level, message, caller, fields_json)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields))
|
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields))
|
||||||
@@ -148,7 +165,17 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return LogEvent{}, fmt.Errorf("read log event id: %w", err)
|
return LogEvent{}, fmt.Errorf("read log event id: %w", err)
|
||||||
}
|
}
|
||||||
value.Fields = fields
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
DELETE FROM log_events
|
||||||
|
WHERE id <= COALESCE((
|
||||||
|
SELECT id FROM log_events ORDER BY id DESC LIMIT 1 OFFSET ?
|
||||||
|
), 0)
|
||||||
|
`, MaxLogEvents); err != nil {
|
||||||
|
return LogEvent{}, fmt.Errorf("enforce log event limit: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return LogEvent{}, fmt.Errorf("commit log append: %w", err)
|
||||||
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +186,10 @@ func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent
|
|||||||
clauses = append(clauses, `level = ?`)
|
clauses = append(clauses, `level = ?`)
|
||||||
args = append(args, strings.ToLower(filter.Level))
|
args = append(args, strings.ToLower(filter.Level))
|
||||||
}
|
}
|
||||||
|
if filter.ExcludeMessage != "" {
|
||||||
|
clauses = append(clauses, `LOWER(TRIM(message)) <> ?`)
|
||||||
|
args = append(args, strings.ToLower(strings.TrimSpace(filter.ExcludeMessage)))
|
||||||
|
}
|
||||||
if !filter.Since.IsZero() {
|
if !filter.Since.IsZero() {
|
||||||
clauses = append(clauses, `event_time >= ?`)
|
clauses = append(clauses, `event_time >= ?`)
|
||||||
args = append(args, filter.Since.UTC().Unix())
|
args = append(args, filter.Since.UTC().Unix())
|
||||||
@@ -236,6 +267,29 @@ func (s *Store) CountLogEvents(ctx context.Context) (int64, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearLogEvents permanently removes all persisted logs. Entries timestamped
|
||||||
|
// at or before clearedAt are also rejected if they were already queued by the
|
||||||
|
// asynchronous persistence worker.
|
||||||
|
func (s *Store) ClearLogEvents(ctx context.Context, clearedAt time.Time) (int64, error) {
|
||||||
|
s.logMu.Lock()
|
||||||
|
defer s.logMu.Unlock()
|
||||||
|
if clearedAt.IsZero() {
|
||||||
|
clearedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(ctx, `DELETE FROM log_events`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("clear log events: %w", err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read cleared log count: %w", err)
|
||||||
|
}
|
||||||
|
if clearedAt.After(s.logClearedAt) {
|
||||||
|
s.logClearedAt = clearedAt
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
|
// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
|
||||||
// rest. keep <= 0 deletes everything.
|
// rest. keep <= 0 deletes everything.
|
||||||
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) {
|
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppendLogEventEnforcesHardLimit(t *testing.T) {
|
||||||
|
database, err := Open(context.Background(), ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
if _, err := database.db.ExecContext(context.Background(), `
|
||||||
|
WITH RECURSIVE sequence(value) AS (
|
||||||
|
SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value <= ?
|
||||||
|
)
|
||||||
|
INSERT INTO log_events(event_time, level, message, caller, fields_json)
|
||||||
|
SELECT value, 'info', 'seed-' || value, '', '{}' FROM sequence
|
||||||
|
`, MaxLogEvents); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||||
|
Level: "info", Message: "newest", Time: time.Now().UTC(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count, err := database.CountLogEvents(context.Background())
|
||||||
|
if err != nil || count != MaxLogEvents {
|
||||||
|
t.Fatalf("CountLogEvents = %d, %v; want %d", count, err, MaxLogEvents)
|
||||||
|
}
|
||||||
|
logs, err := database.ListLogEvents(context.Background(), LogFilter{Limit: 1})
|
||||||
|
if err != nil || len(logs) != 1 || logs[0].Message != "newest" {
|
||||||
|
t.Fatalf("newest log = %#v, %v", logs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearLogEventsRejectsAlreadyQueuedEntries(t *testing.T) {
|
||||||
|
database, err := Open(context.Background(), ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
cutoff := time.Now().UTC()
|
||||||
|
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||||
|
Level: "info", Message: "existing", Time: cutoff.Add(-time.Second),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deleted, err := database.ClearLogEvents(context.Background(), cutoff)
|
||||||
|
if err != nil || deleted != 1 {
|
||||||
|
t.Fatalf("ClearLogEvents = %d, %v", deleted, err)
|
||||||
|
}
|
||||||
|
late, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||||
|
Level: "info", Message: "queued-before-clear", Time: cutoff.Add(-time.Millisecond),
|
||||||
|
})
|
||||||
|
if err != nil || late.ID != 0 {
|
||||||
|
t.Fatalf("old queued append = %+v, %v", late, err)
|
||||||
|
}
|
||||||
|
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||||
|
Level: "info", Message: fmt.Sprintf("new-%d", MaxLogEvents), Time: cutoff.Add(time.Millisecond),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count, err := database.CountLogEvents(context.Background())
|
||||||
|
if err != nil || count != 1 {
|
||||||
|
t.Fatalf("CountLogEvents = %d, %v; want 1", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -468,6 +468,7 @@ type LogEvent struct {
|
|||||||
|
|
||||||
type LogFilter struct {
|
type LogFilter struct {
|
||||||
Level string
|
Level string
|
||||||
|
ExcludeMessage string
|
||||||
Since time.Time
|
Since time.Time
|
||||||
Until time.Time
|
Until time.Time
|
||||||
BeforeID int64
|
BeforeID int64
|
||||||
|
|||||||
+33
-8
@@ -92,15 +92,16 @@ func saveSMSMessage(
|
|||||||
if mergeErr != nil {
|
if mergeErr != nil {
|
||||||
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
|
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
|
||||||
}
|
}
|
||||||
if existingErr == nil && !changed {
|
if existingErr == nil {
|
||||||
// This segment is already folded into the stored row (a periodic modem
|
if !changed {
|
||||||
// rescan redelivers every segment). Leave the row untouched so the
|
if value.Read != existing.Read {
|
||||||
// durable id stays put and Telegram does not re-notify.
|
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
|
return existing, nil
|
||||||
}
|
}
|
||||||
value.Body = mergedBody
|
|
||||||
extra = mergedExtra
|
|
||||||
if existingErr == nil {
|
|
||||||
// A new segment advanced the message. Replace the stale partial row so
|
// A new segment advanced the message. Replace the stale partial row so
|
||||||
// the merged row receives a fresh durable id; the Telegram id-cursor
|
// the merged row receives a fresh durable id; the Telegram id-cursor
|
||||||
// then surfaces the now-more-complete message exactly once. Carry
|
// then surfaces the now-more-complete message exactly once. Carry
|
||||||
@@ -116,6 +117,8 @@ func saveSMSMessage(
|
|||||||
value.Timestamp = existing.Timestamp
|
value.Timestamp = existing.Timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
value.Body = mergedBody
|
||||||
|
extra = mergedExtra
|
||||||
}
|
}
|
||||||
if value.Timestamp.IsZero() {
|
if value.Timestamp.IsZero() {
|
||||||
value.Timestamp = now
|
value.Timestamp = now
|
||||||
@@ -171,7 +174,10 @@ func saveSMSMessage(
|
|||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
parts_total = excluded.parts_total,
|
parts_total = excluded.parts_total,
|
||||||
delivery_state = excluded.delivery_state,
|
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,
|
extra_json = excluded.extra_json,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`,
|
`,
|
||||||
@@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead(
|
|||||||
return affected, nil
|
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
|
// ListSMSContacts derives contacts and thread counters from messages. No
|
||||||
// duplicated contact/thread table can drift out of sync with message history.
|
// duplicated contact/thread table can drift out of sync with message history.
|
||||||
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -20,6 +21,8 @@ var ErrNotFound = errors.New("store: not found")
|
|||||||
// Store owns the SQLite connection used by the process.
|
// Store owns the SQLite connection used by the process.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
logMu sync.Mutex
|
||||||
|
logClearedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Admin struct {
|
type Admin struct {
|
||||||
|
|||||||
@@ -396,8 +396,8 @@ func decimalString(value string) bool {
|
|||||||
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||||
// attributes add specificity, so a constrained MVNO rule wins over its host
|
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||||
// PLMN without weakening the default match for unrelated subscriptions.
|
// PLMN without weakening the default match for unrelated subscriptions.
|
||||||
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
func defaultCarrierProfile() CarrierProfile {
|
||||||
resolved := CarrierProfile{
|
return CarrierProfile{
|
||||||
ID: CarrierProfileStandard,
|
ID: CarrierProfileStandard,
|
||||||
MatchSource: "standard",
|
MatchSource: "standard",
|
||||||
IKEProposal: IKEProposalModern,
|
IKEProposal: IKEProposalModern,
|
||||||
@@ -408,6 +408,13 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
IMSDialURIScheme: "tel",
|
IMSDialURIScheme: "tel",
|
||||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
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
|
bestScore := -1
|
||||||
for _, rule := range carrierProfilesSnapshot() {
|
for _, rule := range carrierProfilesSnapshot() {
|
||||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||||
@@ -415,7 +422,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bestScore = score
|
bestScore = score
|
||||||
resolved = applyCarrierProfileRule(resolved, rule, source, identity)
|
resolved = applyCarrierProfileRule(defaultCarrierProfile(), rule, source, identity)
|
||||||
}
|
}
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
@@ -458,6 +465,8 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
score += 100
|
score += 100
|
||||||
sources = append(sources, "hplmn")
|
sources = append(sources, "hplmn")
|
||||||
hasHomePLMNMatch = true
|
hasHomePLMNMatch = true
|
||||||
|
} else if identity.HomeMCC != "" && identity.HomeMNC != "" {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasSelectorMatch := false
|
hasSelectorMatch := false
|
||||||
@@ -487,7 +496,7 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
score += selector.weight
|
score += selector.weight
|
||||||
sources = append(sources, selector.name)
|
sources = append(sources, selector.name)
|
||||||
hasSelectorMatch = true
|
hasSelectorMatch = true
|
||||||
} else if !hasHomePLMNMatch {
|
} else if !hasHomePLMNMatch || selector.name == "gid1" || selector.name == "gid2" {
|
||||||
return 0, "", false
|
return 0, "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,6 +508,8 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
score += 20
|
score += 20
|
||||||
sources = append(sources, "spn")
|
sources = append(sources, "spn")
|
||||||
hasSelectorMatch = true
|
hasSelectorMatch = true
|
||||||
|
} else {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasHomePLMNMatch && !hasSelectorMatch {
|
if !hasHomePLMNMatch && !hasSelectorMatch {
|
||||||
|
|||||||
@@ -73,3 +73,63 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
|||||||
t.Fatal("standard profile should require SMS contact confirmation")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -661,6 +661,10 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
|||||||
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
||||||
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
||||||
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
||||||
|
case key == "countryoforiginationformat":
|
||||||
|
// PANI is access/session metadata, not a carrier location constant.
|
||||||
|
// The IMS runtime provides one globally and consistently across
|
||||||
|
// REGISTER, MESSAGE, RP-ACK and dialogs, so no profile field is needed.
|
||||||
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
||||||
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"IMSConfig": map[string]any{
|
"IMSConfig": map[string]any{
|
||||||
"EnableWiFiCallingWithoutEntitlement": true,
|
"EnableWiFiCallingWithoutEntitlement": true,
|
||||||
"Signaling": map[string]any{"UseIPSec": true},
|
"Signaling": map[string]any{
|
||||||
|
"UseIPSec": true,
|
||||||
|
"CountryOfOriginationFormat": "PANI",
|
||||||
|
},
|
||||||
"Media": map[string]any{"SupportPCMA": false},
|
"Media": map[string]any{"SupportPCMA": false},
|
||||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4054,6 +4054,19 @@
|
|||||||
"home_plmns": [
|
"home_plmns": [
|
||||||
"23450"
|
"23450"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"mcc": "234",
|
||||||
|
"mnc": "50"
|
||||||
|
},
|
||||||
|
"epdg": {
|
||||||
|
"hostname": "epdg.epc.mnc050.mcc234.pub.3gppnetwork.org"
|
||||||
|
},
|
||||||
|
"ike": {
|
||||||
|
"proposal": "modern"
|
||||||
|
},
|
||||||
|
"ims": {
|
||||||
|
"ipsec_encryption": "aes-cbc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5724,7 +5737,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ipcc-giffgaff-23410",
|
"id": "ipcc-giffgaff-23410",
|
||||||
"match": {
|
"match_any": [
|
||||||
|
{
|
||||||
"home_plmns": [
|
"home_plmns": [
|
||||||
"23410"
|
"23410"
|
||||||
],
|
],
|
||||||
@@ -5732,6 +5746,19 @@
|
|||||||
"508"
|
"508"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"23410"
|
||||||
|
],
|
||||||
|
"spns": [
|
||||||
|
"giffgaff"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"mcc": "234",
|
||||||
|
"mnc": "10"
|
||||||
|
},
|
||||||
"epdg": {
|
"epdg": {
|
||||||
"hostname": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org"
|
"hostname": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org"
|
||||||
},
|
},
|
||||||
@@ -5742,6 +5769,74 @@
|
|||||||
"ipsec_encryption": "aes-cbc"
|
"ipsec_encryption": "aes-cbc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ipcc-o2-23410",
|
"id": "ipcc-o2-23410",
|
||||||
"match_any": [
|
"match_any": [
|
||||||
@@ -6356,6 +6451,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",
|
"id": "ipcc-ee-23433",
|
||||||
"match": {
|
"match": {
|
||||||
@@ -9660,6 +9788,19 @@
|
|||||||
"gid1_prefixes": [
|
"gid1_prefixes": [
|
||||||
"547275554B3030656E"
|
"547275554B3030656E"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"90143",
|
||||||
|
"90128"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spns": [
|
||||||
|
"Truphone",
|
||||||
|
"BetterRoaming",
|
||||||
|
"1GLOBAL"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"epdg": {
|
"epdg": {
|
||||||
|
|||||||
@@ -280,6 +280,239 @@ func decryptPayloads(
|
|||||||
return header, payloads, nil
|
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{
|
var modpPrimes = map[uint16]string{
|
||||||
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
||||||
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
||||||
|
|||||||
@@ -113,3 +113,80 @@ func TestIKEKeyDerivationSeparatesDirections(t *testing.T) {
|
|||||||
t.Fatal("initiator and responder keys were not separated")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -264,8 +264,13 @@ func permanentAKAIdentity(identity vowifi.SIMIdentity) ([]byte, error) {
|
|||||||
return nil, errors.New("ike: IMSI contains a non-digit")
|
return nil, errors.New("ike: IMSI contains a non-digit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
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) {
|
if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) {
|
||||||
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
|
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,21 +29,28 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
|||||||
resolver = net.DefaultResolver
|
resolver = net.DefaultResolver
|
||||||
}
|
}
|
||||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
|
hostsToTry := []string{normalized}
|
||||||
validSystemAddresses := filterValidPublicEPDGAddresses(addresses)
|
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
||||||
if systemErr == nil && len(validSystemAddresses) > 0 {
|
hostsToTry = append(hostsToTry, alt)
|
||||||
return validSystemAddresses, nil
|
}
|
||||||
|
|
||||||
|
var systemErr error
|
||||||
|
for _, targetHost := range hostsToTry {
|
||||||
|
addresses, err := resolver.LookupIPAddr(ctx, targetHost)
|
||||||
|
if err == nil {
|
||||||
|
valid := filterValidPublicEPDGAddresses(addresses)
|
||||||
|
if len(valid) > 0 {
|
||||||
|
return valid, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
systemErr = err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||||
client := &http.Client{Timeout: 8 * time.Second}
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
var fallbackErr error
|
var fallbackErr error
|
||||||
|
|
||||||
hostsToTry := []string{normalized}
|
|
||||||
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
|
||||||
hostsToTry = append(hostsToTry, alt)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, targetHost := range hostsToTry {
|
for _, targetHost := range hostsToTry {
|
||||||
var fallback []net.IPAddr
|
var fallback []net.IPAddr
|
||||||
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
|
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
|
||||||
|
|||||||
+103
-32
@@ -187,6 +187,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
{Type: payloadNonce, Body: initiatorNonce},
|
{Type: payloadNonce, Body: initiatorNonce},
|
||||||
makeNotify(notifyNATSource, sourceHash),
|
makeNotify(notifyNATSource, sourceHash),
|
||||||
makeNotify(notifyNATDestination, destinationHash),
|
makeNotify(notifyNATDestination, destinationHash),
|
||||||
|
makeNotify(notifyFragmentationSupported, nil),
|
||||||
}
|
}
|
||||||
var (
|
var (
|
||||||
initRequest []byte
|
initRequest []byte
|
||||||
@@ -243,6 +244,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
peerSupportsFragmentation := hasNotifyType(initResponsePayloads, notifyFragmentationSupported)
|
||||||
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -345,21 +347,19 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: 1,
|
MessageID: 1,
|
||||||
}
|
}
|
||||||
authRequest, err := encryptPayloads(authHeader, firstAuthPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
_, authResponsePayloads, err := sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
authHeader,
|
||||||
authResponse, err := transport.RoundTrip(ctx, authRequest)
|
firstAuthPayloads,
|
||||||
if err != nil {
|
ikeSuite,
|
||||||
return nil, err
|
keys,
|
||||||
}
|
peerSupportsFragmentation,
|
||||||
authResponseHeader, authResponsePayloads, err := decryptAndValidate(
|
provider.config.Random,
|
||||||
authResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, 1, ikeSuite, keys,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = authResponseHeader
|
|
||||||
serverName := strings.TrimSpace(provider.config.ServerName)
|
serverName := strings.TrimSpace(provider.config.ServerName)
|
||||||
if serverName == "" {
|
if serverName == "" {
|
||||||
serverName = epdg
|
serverName = epdg
|
||||||
@@ -409,27 +409,27 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
requestPayloads = append(requestPayloads, deviceIdentity)
|
requestPayloads = append(requestPayloads, deviceIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eapRequest, err := encryptPayloads(ikeHeader{
|
eapHeader := ikeHeader{
|
||||||
InitiatorSPI: initiatorSPI,
|
InitiatorSPI: initiatorSPI,
|
||||||
ResponderSPI: responseHeader.ResponderSPI,
|
ResponderSPI: responseHeader.ResponderSPI,
|
||||||
Exchange: exchangeIKEAuth,
|
Exchange: exchangeIKEAuth,
|
||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
||||||
return nil, notifyErr
|
return nil, notifyErr
|
||||||
} else if requested {
|
} else if requested {
|
||||||
deviceIdentityPending = true
|
deviceIdentityPending = true
|
||||||
}
|
}
|
||||||
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
|
_, currentPayloads, err = sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
eapHeader,
|
||||||
_, currentPayloads, err = decryptAndValidate(
|
requestPayloads,
|
||||||
eapResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
ikeSuite,
|
||||||
|
keys,
|
||||||
|
peerSupportsFragmentation,
|
||||||
|
provider.config.Random,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -454,22 +454,22 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}
|
}
|
||||||
messageID++
|
messageID++
|
||||||
cleanupMessageID = messageID + 1
|
cleanupMessageID = messageID + 1
|
||||||
finalRequest, err := encryptPayloads(ikeHeader{
|
finalHeader := ikeHeader{
|
||||||
InitiatorSPI: initiatorSPI,
|
InitiatorSPI: initiatorSPI,
|
||||||
ResponderSPI: responseHeader.ResponderSPI,
|
ResponderSPI: responseHeader.ResponderSPI,
|
||||||
Exchange: exchangeIKEAuth,
|
Exchange: exchangeIKEAuth,
|
||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
}, []payload{initiatorAUTH}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
finalResponse, err := transport.RoundTrip(ctx, finalRequest)
|
_, finalPayloads, err := sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
finalHeader,
|
||||||
_, finalPayloads, err := decryptAndValidate(
|
[]payload{initiatorAUTH},
|
||||||
finalResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
ikeSuite,
|
||||||
|
keys,
|
||||||
|
peerSupportsFragmentation,
|
||||||
|
provider.config.Random,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -763,7 +763,7 @@ func decryptAndValidate(
|
|||||||
suite negotiatedSuite,
|
suite negotiatedSuite,
|
||||||
keys ikeKeys,
|
keys ikeKeys,
|
||||||
) (ikeHeader, []payload, error) {
|
) (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 {
|
if err != nil {
|
||||||
return ikeHeader{}, nil, err
|
return ikeHeader{}, nil, err
|
||||||
}
|
}
|
||||||
@@ -778,6 +778,77 @@ func decryptAndValidate(
|
|||||||
return header, payloads, nil
|
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")
|
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||||
|
|
||||||
type invalidKEPayloadError struct {
|
type invalidKEPayloadError struct {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
|
|||||||
return nil
|
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++
|
transport.calls++
|
||||||
if len(transport.cookieChallenge) > 0 {
|
if len(transport.cookieChallenge) > 0 {
|
||||||
switch transport.calls {
|
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) {
|
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
||||||
header, _, err := parseIKEPacket(packet)
|
header, _, err := parseIKEPacket(packet)
|
||||||
if err != nil {
|
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")
|
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
||||||
}
|
}
|
||||||
cookies := payloadsOfType(payloads, payloadNotify)
|
cookies := payloadsOfType(payloads, payloadNotify)
|
||||||
if len(cookies) != 3 {
|
if len(cookies) != 4 {
|
||||||
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", len(cookies))
|
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 4", len(cookies))
|
||||||
}
|
}
|
||||||
found := false
|
found := false
|
||||||
for _, item := range cookies {
|
for _, item := range cookies {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func newSessionRelay(
|
|||||||
keepalive time.Duration,
|
keepalive time.Duration,
|
||||||
) *sessionRelay {
|
) *sessionRelay {
|
||||||
if keepalive <= 0 {
|
if keepalive <= 0 {
|
||||||
keepalive = 20 * time.Second
|
keepalive = 15 * time.Second
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
relay := &sessionRelay{
|
relay := &sessionRelay{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ike
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -49,6 +50,16 @@ func (*fakeSessionTransport) Float(context.Context) error { return nil }
|
|||||||
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
|
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
|
||||||
return nil, context.DeadlineExceeded
|
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 {
|
func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error {
|
||||||
return transport.SendSessionPacket(ctx, packet, false)
|
return transport.SendSessionPacket(ctx, packet, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type datagramTransport interface {
|
|||||||
RemoteAddr() *net.UDPAddr
|
RemoteAddr() *net.UDPAddr
|
||||||
Float(context.Context) error
|
Float(context.Context) error
|
||||||
RoundTrip(context.Context, []byte) ([]byte, error)
|
RoundTrip(context.Context, []byte) ([]byte, error)
|
||||||
|
RoundTripExchange(context.Context, [][]byte) ([][]byte, error)
|
||||||
SendESP(context.Context, []byte) error
|
SendESP(context.Context, []byte) error
|
||||||
ReceiveESP(context.Context, []byte) (int, error)
|
ReceiveESP(context.Context, []byte) (int, error)
|
||||||
SendSessionPacket(context.Context, []byte, bool) error
|
SendSessionPacket(context.Context, []byte, bool) error
|
||||||
@@ -96,6 +97,29 @@ func roundTripDatagram(
|
|||||||
read func([]byte, time.Time) (int, error),
|
read func([]byte, time.Time) (int, error),
|
||||||
packet []byte,
|
packet []byte,
|
||||||
) ([]byte, error) {
|
) ([]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 {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -110,20 +134,44 @@ func roundTripDatagram(
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := write(packet); err != nil {
|
if err := writeAll(packets); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
attemptDeadline := time.Now().Add(interval)
|
attemptDeadline := time.Now().Add(interval)
|
||||||
if deadline.Before(attemptDeadline) {
|
if deadline.Before(attemptDeadline) {
|
||||||
attemptDeadline = deadline
|
attemptDeadline = deadline
|
||||||
}
|
}
|
||||||
|
var (
|
||||||
|
totalExpected uint16
|
||||||
|
fragments = make(map[uint16][]byte)
|
||||||
|
)
|
||||||
for time.Now().Before(attemptDeadline) {
|
for time.Now().Before(attemptDeadline) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n, err := read(buffer, attemptDeadline)
|
n, err := read(buffer, attemptDeadline)
|
||||||
if err == nil {
|
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() {
|
if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@@ -222,26 +270,48 @@ func (transport *directUDP) Float(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, 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()
|
transport.mu.Lock()
|
||||||
defer transport.mu.Unlock()
|
defer transport.mu.Unlock()
|
||||||
if transport.conn == nil {
|
if transport.conn == nil {
|
||||||
return nil, errors.New("ike: UDP transport is closed")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
||||||
}
|
}
|
||||||
wirePacket := packet
|
var wirePackets [][]byte
|
||||||
|
for _, pkt := range packets {
|
||||||
|
wire := pkt
|
||||||
if transport.floated {
|
if transport.floated {
|
||||||
wirePacket = append([]byte{0, 0, 0, 0}, packet...)
|
wire = append([]byte{0, 0, 0, 0}, pkt...)
|
||||||
}
|
}
|
||||||
write := func(value []byte) error {
|
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 {
|
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := transport.conn.Write(value)
|
if _, err := transport.conn.Write(value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||||
for {
|
for {
|
||||||
if err := transport.conn.SetReadDeadline(attemptDeadline); err != nil {
|
if err := transport.conn.SetReadDeadline(attemptDeadline); err != nil {
|
||||||
@@ -252,10 +322,6 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if transport.floated {
|
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]) {
|
if !hasNonESPMarker(buffer[:n]) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -268,7 +334,7 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
return n, nil
|
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 {
|
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) {
|
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()
|
transport.mu.Lock()
|
||||||
defer transport.mu.Unlock()
|
defer transport.mu.Unlock()
|
||||||
if transport.udp == nil {
|
if transport.udp == nil {
|
||||||
return nil, errors.New("ike: SOCKS5 UDP transport is closed")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
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.
|
// 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 {
|
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var cookieResponse []byte
|
var cookieResponse [][]byte
|
||||||
for _, candidate := range transport.remotes {
|
for _, candidate := range transport.remotes {
|
||||||
transport.remote = cloneUDPAddr(candidate)
|
transport.remote = cloneUDPAddr(candidate)
|
||||||
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
responses, attemptErr := transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
|
||||||
if attemptErr == nil {
|
if attemptErr == nil {
|
||||||
if ikeInitResponseHasCookie(response) {
|
if len(responses) > 0 && ikeInitResponseHasCookie(responses[0]) {
|
||||||
if cookieResponse == nil {
|
if cookieResponse == nil {
|
||||||
cookieResponse = append([]byte(nil), response...)
|
cookieResponse = responses
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return response, nil
|
return responses, nil
|
||||||
}
|
}
|
||||||
lastErr = attemptErr
|
lastErr = attemptErr
|
||||||
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
|
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
|
||||||
@@ -599,25 +679,33 @@ 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 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) {
|
func (transport *socks5UDP) roundTripFragmentsLocked(ctx context.Context, packets [][]byte, requestHeader ikeHeader) ([][]byte, error) {
|
||||||
wireIKE := packet
|
var datagrams [][]byte
|
||||||
|
for _, pkt := range packets {
|
||||||
|
wireIKE := pkt
|
||||||
if transport.floated {
|
if transport.floated {
|
||||||
wireIKE = append([]byte{0, 0, 0, 0}, packet...)
|
wireIKE = append([]byte{0, 0, 0, 0}, pkt...)
|
||||||
}
|
}
|
||||||
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
write := func(value []byte) error {
|
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 {
|
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := transport.udp.Write(value)
|
if _, err := transport.udp.Write(value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||||
for {
|
for {
|
||||||
payload, err := readExpectedSOCKS5Datagram(
|
payload, err := readExpectedSOCKS5Datagram(
|
||||||
@@ -630,10 +718,6 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if transport.floated {
|
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) {
|
if !hasNonESPMarker(payload) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -646,7 +730,7 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
|||||||
return len(payload), nil
|
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 {
|
func isNetworkTimeout(err error) bool {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const (
|
|||||||
payloadEncrypted = 46
|
payloadEncrypted = 46
|
||||||
payloadCP = 47
|
payloadCP = 47
|
||||||
payloadEAP = 48
|
payloadEAP = 48
|
||||||
|
payloadEncryptedFragment = 53
|
||||||
|
|
||||||
protocolIKE = 1
|
protocolIKE = 1
|
||||||
protocolESP = 3
|
protocolESP = 3
|
||||||
@@ -61,6 +62,7 @@ const (
|
|||||||
notifyNATDestination = 16389
|
notifyNATDestination = 16389
|
||||||
notifyCookie = 16390
|
notifyCookie = 16390
|
||||||
notifyEAPOnlyAuth = 16417
|
notifyEAPOnlyAuth = 16417
|
||||||
|
notifyFragmentationSupported = 16430
|
||||||
notifyDeviceIdentity = 41101
|
notifyDeviceIdentity = 41101
|
||||||
notifyInvalidKE = 17
|
notifyInvalidKE = 17
|
||||||
notifyNoProposal = 14
|
notifyNoProposal = 14
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
|||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
if session.provider != nil && session.provider.config.Logger != nil {
|
if session.provider != nil && session.provider.config.Logger != nil {
|
||||||
session.provider.config.Logger.Info("IMS call started",
|
session.provider.config.Logger.Info("IMS call started",
|
||||||
|
"category", "call",
|
||||||
|
"device_id", session.request.DeviceID,
|
||||||
"direction", "outgoing",
|
"direction", "outgoing",
|
||||||
"identity_source", identitySource,
|
"identity_source", identitySource,
|
||||||
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||||
@@ -228,6 +230,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
|||||||
} else {
|
} else {
|
||||||
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
|
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||||
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
|
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
|
||||||
|
"category", "call",
|
||||||
|
"device_id", session.request.DeviceID,
|
||||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||||
"sip_status", response.StatusCode,
|
"sip_status", response.StatusCode,
|
||||||
"error", safeSIPDiagnostic(ackErr.Error()),
|
"error", safeSIPDiagnostic(ackErr.Error()),
|
||||||
@@ -368,6 +372,14 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
|||||||
session.callMu.Lock()
|
session.callMu.Lock()
|
||||||
session.calls[callID] = call
|
session.calls[callID] = call
|
||||||
session.callMu.Unlock()
|
session.callMu.Unlock()
|
||||||
|
if session.provider != nil && session.provider.config.Logger != nil {
|
||||||
|
session.provider.config.Logger.Info("IMS incoming call received",
|
||||||
|
"category", "call",
|
||||||
|
"device_id", session.request.DeviceID,
|
||||||
|
"caller", call.public.Number,
|
||||||
|
"call_id", call.public.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
|
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
|
||||||
calledNumber := identityNumber(request.value("To"))
|
calledNumber := identityNumber(request.value("To"))
|
||||||
if calledNumber == "" {
|
if calledNumber == "" {
|
||||||
@@ -799,16 +811,10 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) pAccessNetworkInfo() string {
|
func (session *Session) pAccessNetworkInfo() string {
|
||||||
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
|
if session.paniResolved {
|
||||||
node := strings.TrimSpace(profile.PANINode)
|
return ueProvidedPANI(session.pani)
|
||||||
if node == "" {
|
|
||||||
node = "000000000000"
|
|
||||||
}
|
}
|
||||||
value := "IEEE-802.11;i-wlan-node-id=" + node
|
return sessionPAccessNetworkInfo(session.instanceID)
|
||||||
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
|
||||||
value += ";country=" + country
|
|
||||||
}
|
|
||||||
return value + ";network-provided"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) callUserAgent() string {
|
func (session *Session) callUserAgent() string {
|
||||||
@@ -843,6 +849,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
session.provider.config.Logger.Info("IMS call response",
|
session.provider.config.Logger.Info("IMS call response",
|
||||||
|
"category", "call",
|
||||||
|
"device_id", session.request.DeviceID,
|
||||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||||
"sip_status", response.StatusCode,
|
"sip_status", response.StatusCode,
|
||||||
"diagnostic", diagnostic,
|
"diagnostic", diagnostic,
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
|||||||
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||||
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||||
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided\r\n",
|
"P-Access-Network-Info: " + sessionPAccessNetworkInfo(session.instanceID) + "\r\n",
|
||||||
"User-Agent: VoCat Test\r\n",
|
"User-Agent: VoCat Test\r\n",
|
||||||
"Accept: application/sdp\r\n",
|
"Accept: application/sdp\r\n",
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -230,21 +231,27 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pcscf := provider.config.PCSCF
|
var pcscfCandidates []string
|
||||||
if pcscf == "" {
|
if provider.config.PCSCF != "" {
|
||||||
|
pcscfCandidates = []string{provider.config.PCSCF}
|
||||||
|
} else {
|
||||||
for _, candidate := range tunnel.PCSCF {
|
for _, candidate := range tunnel.PCSCF {
|
||||||
if strings.TrimSpace(candidate) != "" {
|
candidate = strings.TrimSpace(candidate)
|
||||||
pcscf = candidate
|
if candidate != "" {
|
||||||
break
|
pcscfCandidates = append(pcscfCandidates, candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pcscf == "" {
|
if len(pcscfCandidates) == 0 {
|
||||||
return nil, errors.New("ims: tunnel did not provide a P-CSCF")
|
return nil, errors.New("ims: tunnel did not provide a P-CSCF")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for pcscfIndex, pcscf := range pcscfCandidates {
|
||||||
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
|
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
lastErr = err
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
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")
|
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||||
@@ -290,7 +297,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
}
|
}
|
||||||
transports = append(transports, alternate)
|
transports = append(transports, alternate)
|
||||||
}
|
}
|
||||||
var lastErr error
|
|
||||||
for attempt, candidate := range transports {
|
for attempt, candidate := range transports {
|
||||||
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||||
if dialErr != nil {
|
if dialErr != nil {
|
||||||
@@ -299,17 +305,18 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
break
|
||||||
}
|
}
|
||||||
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||||
if sessionErr != nil {
|
if sessionErr != nil {
|
||||||
_ = connection.Close()
|
_ = connection.Close()
|
||||||
return nil, sessionErr
|
lastErr = sessionErr
|
||||||
|
break
|
||||||
}
|
}
|
||||||
establishErr := session.establish(ctx)
|
establishErr := session.establish(ctx)
|
||||||
if establishErr == nil {
|
if establishErr == nil {
|
||||||
provider.rememberTransport(request.Identity, candidate)
|
provider.rememberTransport(request.Identity, candidate)
|
||||||
if attempt > 0 {
|
if attempt > 0 || pcscfIndex > 0 {
|
||||||
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
||||||
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
||||||
"transport", candidate)
|
"transport", candidate)
|
||||||
@@ -320,10 +327,14 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
session.abort()
|
session.abort()
|
||||||
lastErr = establishErr
|
lastErr = establishErr
|
||||||
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||||
return nil, lastErr
|
break
|
||||||
}
|
}
|
||||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||||
}
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +395,13 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
|||||||
if !digitsBetween(imsi, 5, 16) {
|
if !digitsBetween(imsi, 5, 16) {
|
||||||
return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid")
|
return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid")
|
||||||
}
|
}
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
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) {
|
if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) {
|
||||||
return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid")
|
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)
|
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||||
privateDomain := domain
|
privateDomain := domain
|
||||||
publicDomain := 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
|
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||||
// the generic 3GPP PLMN IMS domain.
|
// the generic 3GPP PLMN IMS domain.
|
||||||
domain = "one.att.net"
|
domain = "one.att.net"
|
||||||
@@ -579,6 +595,8 @@ type Session struct {
|
|||||||
callID string
|
callID string
|
||||||
fromTag string
|
fromTag string
|
||||||
instanceID string
|
instanceID string
|
||||||
|
pani string
|
||||||
|
paniResolved bool
|
||||||
cseq uint32
|
cseq uint32
|
||||||
auth *authenticationState
|
auth *authenticationState
|
||||||
securityProposal securityProposal
|
securityProposal securityProposal
|
||||||
@@ -643,6 +661,8 @@ func newSession(
|
|||||||
callID: callToken + "@" + addressHost(connection.LocalAddr()),
|
callID: callToken + "@" + addressHost(connection.LocalAddr()),
|
||||||
fromTag: fromTag,
|
fromTag: fromTag,
|
||||||
instanceID: "urn:uuid:" + instanceID,
|
instanceID: "urn:uuid:" + instanceID,
|
||||||
|
pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID),
|
||||||
|
paniResolved: true,
|
||||||
cseq: 1,
|
cseq: 1,
|
||||||
refreshContext: refreshContext,
|
refreshContext: refreshContext,
|
||||||
refreshCancel: refreshCancel,
|
refreshCancel: refreshCancel,
|
||||||
@@ -976,19 +996,16 @@ func (session *Session) buildRegister(
|
|||||||
}
|
}
|
||||||
lines = append(lines, "User-Agent: "+userAgent)
|
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 {
|
if registerOptions.PPreferredIdentity {
|
||||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
||||||
lines = append(lines, `P-Visited-Network-ID: "`+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)
|
lines = append(lines, "P-Access-Network-Info: "+pani)
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
||||||
@@ -1059,6 +1076,58 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionPAccessNetworkInfo creates a syntactically valid, locally
|
||||||
|
// administered unicast WLAN node identifier from the already-random SIP
|
||||||
|
// instance ID. It discloses neither a real BSSID nor subscriber identity, but
|
||||||
|
// remains stable for every transaction belonging to this IMS registration.
|
||||||
|
func sessionPAccessNetworkInfo(instanceID string) string {
|
||||||
|
instanceID = strings.TrimSpace(instanceID)
|
||||||
|
if instanceID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(instanceID))
|
||||||
|
digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast
|
||||||
|
return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6])
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSessionPAccessNetworkInfo freezes the selected value when the IMS
|
||||||
|
// session is created. This prevents a carrier-profile reload from changing
|
||||||
|
// access identity between REGISTER, SMS MESSAGE and its RP-ACK.
|
||||||
|
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, instanceID string) string {
|
||||||
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
|
if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil {
|
||||||
|
return ueProvidedPANI(*configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
node := strings.ToLower(strings.TrimSpace(profile.PANINode))
|
||||||
|
if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 {
|
||||||
|
node = strings.TrimPrefix(sessionPAccessNetworkInfo(instanceID), "IEEE-802.11;i-wlan-node-id=")
|
||||||
|
}
|
||||||
|
if node == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value := "IEEE-802.11;i-wlan-node-id=" + node
|
||||||
|
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" {
|
||||||
|
value += ";country=" + country
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ims
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -361,6 +362,7 @@ func TestRefreshFailureRevokesRegistrationEvidence(t *testing.T) {
|
|||||||
|
|
||||||
func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) error {
|
func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) error {
|
||||||
var callID string
|
var callID string
|
||||||
|
var pani string
|
||||||
for step := 0; step < 4; step++ {
|
for step := 0; step < 4; step++ {
|
||||||
packet := make([]byte, 65535)
|
packet := make([]byte, 65535)
|
||||||
count, remote, err := listener.ReadFromUDP(packet)
|
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" {
|
currentPANI := headers["p-access-network-info"]
|
||||||
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", 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") ||
|
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||||
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||||
@@ -497,6 +505,45 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
|
||||||
|
instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001"
|
||||||
|
first := sessionPAccessNetworkInfo(instanceID)
|
||||||
|
second := sessionPAccessNetworkInfo(instanceID)
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second)
|
||||||
|
}
|
||||||
|
if err := validateTestPANI(first); err != nil {
|
||||||
|
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{paniResolved: true}).pAccessNetworkInfo(); got != "" {
|
||||||
|
t.Fatalf("explicitly omitted session PANI = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTestPANI(value string) error {
|
||||||
|
const prefix = "IEEE-802.11;i-wlan-node-id="
|
||||||
|
if !strings.HasPrefix(value, prefix) {
|
||||||
|
return fmt.Errorf("value %q does not start with %q", value, prefix)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(value), "network-provided") {
|
||||||
|
return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value)
|
||||||
|
}
|
||||||
|
node, err := hex.DecodeString(strings.TrimPrefix(value, prefix))
|
||||||
|
if err != nil || len(node) != 6 {
|
||||||
|
return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value)
|
||||||
|
}
|
||||||
|
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 {
|
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||||
var callID string
|
var callID string
|
||||||
for step := 0; step < 3; step++ {
|
for step := 0; step < 3; step++ {
|
||||||
|
|||||||
@@ -71,7 +71,16 @@ func (media *rtpMedia) ready() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (media *rtpMedia) offerSDP(local net.IP) []byte {
|
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 {
|
func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
||||||
@@ -81,8 +90,12 @@ func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
|||||||
if codec == "" {
|
if codec == "" {
|
||||||
return media.offerSDP(local)
|
return media.offerSDP(local)
|
||||||
}
|
}
|
||||||
|
rate := 8000
|
||||||
|
if codec == "AMR-WB" {
|
||||||
|
rate = 16000
|
||||||
|
}
|
||||||
return media.buildSDP(local, strconv.Itoa(int(payload)), []string{
|
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),
|
fmt.Sprintf("m=audio %d RTP/AVP %s", port, formats),
|
||||||
}
|
}
|
||||||
if attributes == nil {
|
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 {
|
} else {
|
||||||
lines = append(lines, attributes...)
|
lines = append(lines, attributes...)
|
||||||
}
|
}
|
||||||
@@ -137,15 +159,24 @@ func (media *rtpMedia) configureRemote(body []byte) error {
|
|||||||
name = "PCMU"
|
name = "PCMU"
|
||||||
case 8:
|
case 8:
|
||||||
name = "PCMA"
|
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)
|
codec, payload = name, byte(parsed)
|
||||||
break
|
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 == "" {
|
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.mu.Lock()
|
||||||
media.remote = &net.UDPAddr{IP: address, Port: port}
|
media.remote = &net.UDPAddr{IP: address, Port: port}
|
||||||
|
|||||||
@@ -911,7 +911,7 @@ func (session *Session) logInboundSMS(level slog.Level, message string, request
|
|||||||
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
|
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||||
logger = session.provider.config.Logger
|
logger = session.provider.config.Logger
|
||||||
}
|
}
|
||||||
base := []any{"device_id", session.request.DeviceID}
|
base := []any{"category", "sms", "subsystem", "ims", "device_id", session.request.DeviceID}
|
||||||
if request != nil {
|
if request != nil {
|
||||||
base = append(base,
|
base = append(base,
|
||||||
"call_id", strings.TrimSpace(request.value("Call-ID")),
|
"call_id", strings.TrimSpace(request.value("Call-ID")),
|
||||||
@@ -1091,6 +1091,8 @@ func (session *Session) logOutboundSMS(level slog.Level, message string, attribu
|
|||||||
}
|
}
|
||||||
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
|
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
|
||||||
base := []any{
|
base := []any{
|
||||||
|
"category", "sms",
|
||||||
|
"subsystem", "ims",
|
||||||
"device_id", session.request.DeviceID,
|
"device_id", session.request.DeviceID,
|
||||||
"home_plmn", plmn,
|
"home_plmn", plmn,
|
||||||
"transport", session.transport,
|
"transport", session.transport,
|
||||||
@@ -1166,6 +1168,9 @@ func (session *Session) sendSIPMessageWith(
|
|||||||
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
|
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
|
||||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||||
)
|
)
|
||||||
|
if pani := session.pAccessNetworkInfo(); pani != "" {
|
||||||
|
lines = append(lines, "P-Access-Network-Info: "+pani)
|
||||||
|
}
|
||||||
if acceptContactTag != "" {
|
if acceptContactTag != "" {
|
||||||
lines = append(lines, "Accept-Contact: *;+g.3gpp."+acceptContactTag)
|
lines = append(lines, "Accept-Contact: *;+g.3gpp."+acceptContactTag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,10 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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"]
|
callID := headers["call-id"]
|
||||||
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", callID, headers["cseq"], []string{
|
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"`,
|
`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 {
|
if err != nil {
|
||||||
return err
|
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{
|
if _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], []string{
|
||||||
"Contact: " + headers["contact"] + ";expires=600",
|
"Contact: " + headers["contact"] + ";expires=600",
|
||||||
}), remote); err != nil {
|
}), 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 {
|
len(report.Request.Body) != 2 || report.Request.Body[0] != 0x02 || report.Request.Body[1] != 0x2a {
|
||||||
return fmt.Errorf("unexpected delivery report %#v", report.Request)
|
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 {
|
if _, err = listener.WriteToUDP(testResponse(200, "OK", report.Request.value("Call-ID"), report.Request.value("CSeq"), nil), remote); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -449,6 +459,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
|
|||||||
if headers["expires"] != "0" {
|
if headers["expires"] != "0" {
|
||||||
return errors.New("expected deregistration")
|
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)
|
_, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], nil), remote)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -463,6 +476,10 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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"]
|
registerCallID := headers["call-id"]
|
||||||
if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", registerCallID, headers["cseq"], []string{
|
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"`,
|
`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 {
|
if err != nil {
|
||||||
return err
|
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{
|
if _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], []string{
|
||||||
"Contact: " + headers["contact"] + ";expires=600",
|
"Contact: " + headers["contact"] + ";expires=600",
|
||||||
}), remote); err != nil {
|
}), remote); err != nil {
|
||||||
@@ -508,6 +528,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
|||||||
message.Request.value("Allow") != "MESSAGE" {
|
message.Request.value("Allow") != "MESSAGE" {
|
||||||
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
|
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)
|
rpdu, err := parseRPDU(message.Request.Body)
|
||||||
if err != nil || rpdu.messageType != 0 || len(rpdu.tpdu) != 0 {
|
if err != nil || rpdu.messageType != 0 || len(rpdu.tpdu) != 0 {
|
||||||
// parseRPDU intentionally decodes only network-to-MS RP-DATA; inspect
|
// 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 {
|
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)
|
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 {
|
if _, err = listener.WriteToUDP(testResponse(200, "OK", statusACK.Request.value("Call-ID"), statusACK.Request.value("CSeq"), nil), remote); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -589,6 +615,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
|
|||||||
if headers["expires"] != "0" {
|
if headers["expires"] != "0" {
|
||||||
return errors.New("expected deregistration")
|
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)
|
_, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20",
|
DeviceID: "ec20",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
ProfileName: "Vodafone UK",
|
ProfileName: "Vodafone UK",
|
||||||
UpstreamProxyID: "clash",
|
UpstreamProxyID: "clash",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -46,7 +46,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(
|
route, err := (ProxyResolver{Store: database}).Resolve(
|
||||||
context.Background(),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
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 {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
||||||
@@ -169,7 +169,7 @@ func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
request := vowifi.ProxyRequest{
|
request := vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
}
|
}
|
||||||
resolver := ProxyResolver{Store: database}
|
resolver := ProxyResolver{Store: database}
|
||||||
route, err := resolver.Resolve(ctx, request)
|
route, err := resolver.Resolve(ctx, request)
|
||||||
@@ -214,7 +214,7 @@ func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testin
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
iccid := "89441000400128014257"
|
iccid := "8944100000000000001"
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -257,12 +257,12 @@ func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
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 {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -400,10 +400,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if err := projector.Save(context.Background(), vowifi.State{
|
if err := projector.Save(context.Background(), vowifi.State{
|
||||||
DeviceID: "ec20",
|
DeviceID: "ec20",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
IMSI: "234159608751160",
|
IMSI: "234150000000001",
|
||||||
Phase: vowifi.PhaseStopping,
|
Phase: vowifi.PhaseStopping,
|
||||||
PhoneNumber: "+447386083638",
|
PhoneNumber: "+447700900123",
|
||||||
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
|
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -413,10 +413,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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)
|
t.Fatalf("runtime phone = %q", runtime.LocalPhone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,237 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
||||||
import { useI18n, tf } from "../../lib/i18n";
|
import { useI18n, tf } from "../../lib/i18n";
|
||||||
import { cx } from "../../lib/utils";
|
import { cx } from "../../lib/utils";
|
||||||
|
|
||||||
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
|
interface DayUptime {
|
||||||
type RateLevel = "green" | "yellow" | "orange" | "red";
|
dateKey: string; // YYYY-MM-DD
|
||||||
|
date: Date;
|
||||||
function rateLevel(percent: number): RateLevel {
|
isToday: boolean;
|
||||||
if (percent >= 100) return "green";
|
daysAgo: number;
|
||||||
if (percent >= 80) return "yellow";
|
uptimePercent: number; // 0 - 100
|
||||||
if (percent >= 50) return "orange";
|
status: "online" | "degraded" | "down" | "none";
|
||||||
return "red";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
|
const STORAGE_KEY = "vocat_uptime_history_14d";
|
||||||
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: "良好" },
|
function get14DaysSlots(currentOnline: number, currentTotal: number): DayUptime[] {
|
||||||
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
|
let savedMap: Record<string, number> = {};
|
||||||
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
|
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, 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 (
|
||||||
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
<div className="ui-panel relative flex flex-col justify-between p-4 transition-all">
|
||||||
const { t } = useI18n();
|
{/* Header */}
|
||||||
const percent = total > 0 ? Math.round((online / total) * 100) : null;
|
<div>
|
||||||
const level = percent === null ? null : rateLevel(percent);
|
<div className="flex items-center justify-between">
|
||||||
const styles = level ? LEVEL_STYLES[level] : null;
|
<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 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>
|
||||||
|
</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 (
|
return (
|
||||||
<div className="ui-panel p-4">
|
<div
|
||||||
<div className="mb-1 flex items-center gap-2">
|
key={slot.dateKey}
|
||||||
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
|
onMouseEnter={() => setHoveredDay(slot)}
|
||||||
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
onMouseLeave={() => setHoveredDay(null)}
|
||||||
</div>
|
className="group/bar relative flex-1 h-full flex items-end cursor-pointer"
|
||||||
<div className="flex items-center justify-center py-1">
|
>
|
||||||
{percent === null ? (
|
<div
|
||||||
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</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>
|
||||||
) : (
|
) : (
|
||||||
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
|
<span className="text-[10px] text-gray-400 font-normal">
|
||||||
{percent}
|
{tf("{days}天前", { days: slot.daysAgo })}
|
||||||
<span className="text-2xl">%</span>
|
</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>
|
</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">
|
</div>
|
||||||
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
|
|
||||||
{t(styles.labelKey)}
|
{/* Legend / Range labels */}
|
||||||
</span>
|
<div className="mt-1 flex items-center justify-between text-[10px] font-medium text-gray-400 dark:text-gray-500">
|
||||||
) : null}
|
<span>{t("14天前")}</span>
|
||||||
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
|
<span className="opacity-75">{t("持续监测中")}</span>
|
||||||
|
<span>{t("今天")}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import { message } from "../ui/message";
|
|||||||
type RetentionMode = LoggingSettings["mode"];
|
type RetentionMode = LoggingSettings["mode"];
|
||||||
|
|
||||||
// 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。
|
// 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。
|
||||||
export function LogRetentionCard() {
|
export function LogRetentionCard({ refreshKey = 0 }: { refreshKey?: number }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [mode, setMode] = useState<RetentionMode>("unlimited");
|
const [mode, setMode] = useState<RetentionMode>("unlimited");
|
||||||
const [count, setCount] = useState(10000);
|
const [count, setCount] = useState(10000);
|
||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
const [storedLogs, setStoredLogs] = useState(0);
|
const [storedLogs, setStoredLogs] = useState(0);
|
||||||
|
const [maxLogs, setMaxLogs] = useState(10000);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function LogRetentionCard() {
|
|||||||
setCount(data.count);
|
setCount(data.count);
|
||||||
setDays(data.days);
|
setDays(data.days);
|
||||||
setStoredLogs(data.storedLogs);
|
setStoredLogs(data.storedLogs);
|
||||||
|
setMaxLogs(data.maxLogs || 10000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,14 +43,14 @@ export function LogRetentionCard() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [apply]);
|
}, [apply, refreshKey]);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const data = await updateLoggingSettings({
|
const data = await updateLoggingSettings({
|
||||||
mode,
|
mode,
|
||||||
count: Math.max(1, Math.trunc(count) || 1),
|
count: Math.min(maxLogs, Math.max(1, Math.trunc(count) || 1)),
|
||||||
days: Math.max(1, Math.trunc(days) || 1),
|
days: Math.max(1, Math.trunc(days) || 1),
|
||||||
});
|
});
|
||||||
apply(data);
|
apply(data);
|
||||||
@@ -58,7 +60,7 @@ export function LogRetentionCard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [mode, count, days, apply]);
|
}, [mode, count, days, maxLogs, apply]);
|
||||||
|
|
||||||
const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const parsed = parseInt(event.target.value, 10);
|
const parsed = parseInt(event.target.value, 10);
|
||||||
@@ -78,7 +80,7 @@ export function LogRetentionCard() {
|
|||||||
className="w-32"
|
className="w-32"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
options={[
|
options={[
|
||||||
{ value: "unlimited", label: t("不限制") },
|
{ value: "unlimited", label: t("最多 10000 条") },
|
||||||
{ value: "count", label: t("按条数") },
|
{ value: "count", label: t("按条数") },
|
||||||
{ value: "days", label: t("按天数") },
|
{ value: "days", label: t("按天数") },
|
||||||
]}
|
]}
|
||||||
@@ -88,6 +90,7 @@ export function LogRetentionCard() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
max={maxLogs}
|
||||||
value={count === 0 ? "" : count}
|
value={count === 0 ? "" : count}
|
||||||
onChange={onNumber(setCount)}
|
onChange={onNumber(setCount)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -110,8 +113,9 @@ export function LogRetentionCard() {
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{t("当前已存储")} {storedLogs} {t("条")}
|
{t("当前已存储")} {storedLogs} / {maxLogs} {t("条")}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{t("达到上限后自动删除最旧日志")}</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -498,6 +498,22 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"连接中断,正在尝试重连…": "Connection lost, reconnecting…",
|
"连接中断,正在尝试重连…": "Connection lost, reconnecting…",
|
||||||
已导出日志: "Logs exported",
|
已导出日志: "Logs exported",
|
||||||
"查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search",
|
"查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search",
|
||||||
|
设备与业务日志: "Device & Service Logs",
|
||||||
|
"记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码":
|
||||||
|
"Hardware, network registration, WiFi Calling, SMS, calls, and user operations. Sensitive identities are automatically redacted.",
|
||||||
|
业务分类: "Category",
|
||||||
|
全部业务: "All Services",
|
||||||
|
硬件与模块: "Hardware & Modem",
|
||||||
|
驻网: "Network Registration",
|
||||||
|
通话: "Calls",
|
||||||
|
用户操作: "User Operations",
|
||||||
|
系统错误: "System",
|
||||||
|
"最多 10000 条": "Up to 10,000",
|
||||||
|
达到上限后自动删除最旧日志: "The oldest logs are automatically removed at the limit",
|
||||||
|
"此操作会永久删除服务端保存的全部日志,无法恢复。": "This permanently deletes all logs stored on the server and cannot be undone.",
|
||||||
|
"确认清空日志?": "Clear all logs?",
|
||||||
|
日志已清空: "Logs cleared",
|
||||||
|
清空日志失败: "Failed to clear logs",
|
||||||
继续: "Resume",
|
继续: "Resume",
|
||||||
暂停: "Pause",
|
暂停: "Pause",
|
||||||
已连接: "Connected",
|
已连接: "Connected",
|
||||||
@@ -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.",
|
"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",
|
"{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 ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+108
-21
@@ -16,11 +16,13 @@ import { Switch } from "../components/ui/Switch";
|
|||||||
import { Select } from "../components/ui/Select";
|
import { Select } from "../components/ui/Select";
|
||||||
import { Input } from "../components/ui/Input";
|
import { Input } from "../components/ui/Input";
|
||||||
import { message } from "../components/ui/message";
|
import { message } from "../components/ui/message";
|
||||||
|
import { confirmDialog } from "../components/ui/MessageBox";
|
||||||
import { LogRetentionCard } from "../components/logs/LogRetentionCard";
|
import { LogRetentionCard } from "../components/logs/LogRetentionCard";
|
||||||
|
|
||||||
const MAX_LOGS = 1000;
|
const MAX_LOGS = 1000;
|
||||||
|
|
||||||
type Level = "all" | "debug" | "info" | "warn" | "error";
|
type Level = "all" | "debug" | "info" | "warn" | "error";
|
||||||
|
type Category = "all" | "hardware" | "network" | "vowifi" | "sms" | "call" | "operation" | "system";
|
||||||
|
|
||||||
const LEVEL_OPTIONS: { value: Level; label: string }[] = [
|
const LEVEL_OPTIONS: { value: Level; label: string }[] = [
|
||||||
{ value: "all", label: "全部" },
|
{ value: "all", label: "全部" },
|
||||||
@@ -30,6 +32,17 @@ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
|
|||||||
{ value: "error", label: "ERROR" },
|
{ value: "error", label: "ERROR" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS: { value: Category; label: string }[] = [
|
||||||
|
{ value: "all", label: "全部业务" },
|
||||||
|
{ value: "hardware", label: "硬件与模块" },
|
||||||
|
{ value: "network", label: "驻网" },
|
||||||
|
{ value: "vowifi", label: "WiFi Calling" },
|
||||||
|
{ value: "sms", label: "短信" },
|
||||||
|
{ value: "call", label: "通话" },
|
||||||
|
{ value: "operation", label: "用户操作" },
|
||||||
|
{ value: "system", label: "系统错误" },
|
||||||
|
];
|
||||||
|
|
||||||
function levelColor(level: string): string {
|
function levelColor(level: string): string {
|
||||||
switch (level.toLowerCase()) {
|
switch (level.toLowerCase()) {
|
||||||
case "debug":
|
case "debug":
|
||||||
@@ -52,6 +65,42 @@ function fieldsText(fields: LogEntry["fields"]): string {
|
|||||||
return typeof fields === "string" ? fields : JSON.stringify(fields);
|
return typeof fields === "string" ? fields : JSON.stringify(fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logFields(entry: LogEntry): Record<string, unknown> {
|
||||||
|
return entry.fields && typeof entry.fields === "object" ? entry.fields : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logCategory(entry: LogEntry): Exclude<Category, "all"> {
|
||||||
|
const explicit = String(logFields(entry).category ?? "").toLowerCase();
|
||||||
|
if (CATEGORY_OPTIONS.some((item) => item.value === explicit && item.value !== "all")) {
|
||||||
|
return explicit as Exclude<Category, "all">;
|
||||||
|
}
|
||||||
|
const text = `${entry.message} ${fieldsText(entry.fields)}`.toLowerCase();
|
||||||
|
if (/\bsms\b|短信|tpdu|rp-data|rpdu/.test(text)) return "sms";
|
||||||
|
if (/incoming call|\bcall\b|invite|来电|通话/.test(text)) return "call";
|
||||||
|
if (/vowifi|wi-?fi calling|\bims\b|\bike\b|epdg|ipsec/.test(text)) return "vowifi";
|
||||||
|
if (/registration|operator|network|驻网|注册网络/.test(text)) return "network";
|
||||||
|
if (/device|modem|hardware|sim|uicc|esim|qmi|串口|模块|设备/.test(text)) return "hardware";
|
||||||
|
if (/operation|audit|setting|操作/.test(text)) return "operation";
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryColor(category: Exclude<Category, "all">): string {
|
||||||
|
switch (category) {
|
||||||
|
case "hardware": return "bg-cyan-500/15 text-cyan-300";
|
||||||
|
case "network": return "bg-emerald-500/15 text-emerald-300";
|
||||||
|
case "vowifi": return "bg-sky-500/15 text-sky-300";
|
||||||
|
case "sms": return "bg-violet-500/15 text-violet-300";
|
||||||
|
case "call": return "bg-pink-500/15 text-pink-300";
|
||||||
|
case "operation": return "bg-amber-500/15 text-amber-300";
|
||||||
|
default: return "bg-gray-500/20 text-gray-300";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHTTPAccessLog(entry: LogEntry): boolean {
|
||||||
|
return entry.message.trim().toLowerCase() === "http request" ||
|
||||||
|
String(logFields(entry).category ?? "").toLowerCase() === "http_access";
|
||||||
|
}
|
||||||
|
|
||||||
// Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp.
|
// Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp.
|
||||||
function displayTime(time: string): string {
|
function displayTime(time: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -71,8 +120,11 @@ export default function LogsPage() {
|
|||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [autoTail, setAutoTail] = useState(true);
|
const [autoTail, setAutoTail] = useState(true);
|
||||||
const [level, setLevel] = useState<Level>("all");
|
const [level, setLevel] = useState<Level>("all");
|
||||||
|
const [category, setCategory] = useState<Category>("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [connError, setConnError] = useState("");
|
const [connError, setConnError] = useState("");
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [retentionRefreshKey, setRetentionRefreshKey] = useState(0);
|
||||||
|
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -80,6 +132,7 @@ export default function LogsPage() {
|
|||||||
const levelRef = useRef<Level>("all");
|
const levelRef = useRef<Level>("all");
|
||||||
|
|
||||||
const appendLog = useCallback((entry: LogEntry) => {
|
const appendLog = useCallback((entry: LogEntry) => {
|
||||||
|
if (isHTTPAccessLog(entry)) return;
|
||||||
setLogs((prev) => {
|
setLogs((prev) => {
|
||||||
const next = [...prev, entry];
|
const next = [...prev, entry];
|
||||||
return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
|
return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
|
||||||
@@ -118,7 +171,7 @@ export default function LogsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500");
|
const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500");
|
||||||
const list = Array.isArray(res) ? res : (res?.logs ?? []);
|
const list = Array.isArray(res) ? res : (res?.logs ?? []);
|
||||||
setLogs(list.slice(-MAX_LOGS));
|
setLogs(list.filter((entry) => !isHTTPAccessLog(entry)).slice(-MAX_LOGS));
|
||||||
} catch {
|
} catch {
|
||||||
/* 历史回填失败不阻塞实时流 */
|
/* 历史回填失败不阻塞实时流 */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -164,13 +217,34 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
const clearLogs = useCallback(() => setLogs([]), []);
|
const clearLogs = useCallback(async () => {
|
||||||
|
const confirmed = await confirmDialog(
|
||||||
|
t("此操作会永久删除服务端保存的全部日志,无法恢复。"),
|
||||||
|
t("确认清空日志?"),
|
||||||
|
{ type: "warning", confirmText: t("清空"), cancelText: t("取消") },
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
setClearing(true);
|
||||||
|
try {
|
||||||
|
await api<{ cleared: boolean; deleted: number }>("/logs/history", { method: "DELETE" });
|
||||||
|
setLogs([]);
|
||||||
|
setRetentionRefreshKey((value) => value + 1);
|
||||||
|
message.success(t("日志已清空"));
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : t("清空日志失败"));
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = logs;
|
let list = logs;
|
||||||
if (level !== "all") {
|
if (level !== "all") {
|
||||||
list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase());
|
list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase());
|
||||||
}
|
}
|
||||||
|
if (category !== "all") {
|
||||||
|
list = list.filter((entry) => logCategory(entry) === category);
|
||||||
|
}
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
@@ -181,14 +255,14 @@ export default function LogsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [logs, level, search]);
|
}, [logs, level, category, search]);
|
||||||
|
|
||||||
const exportLogs = useCallback(() => {
|
const exportLogs = useCallback(() => {
|
||||||
const text = filtered
|
const text = filtered
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
const time = new Date(v.time).toLocaleString();
|
const time = new Date(v.time).toLocaleString();
|
||||||
const fields = v.fields ? ` ${fieldsText(v.fields)}` : "";
|
const fields = v.fields ? ` ${fieldsText(v.fields)}` : "";
|
||||||
return `[${time}] ${v.level.toUpperCase().padEnd(5)} ${v.caller ?? ""} ${v.message}${fields}`;
|
return `[${time}] ${v.level.toUpperCase().padEnd(5)} [${logCategory(v)}] ${v.caller ?? ""} ${v.message}${fields}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const blob = new Blob([text], { type: "text/plain" });
|
const blob = new Blob([text], { type: "text/plain" });
|
||||||
@@ -204,8 +278,8 @@ export default function LogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("实时日志")}
|
title={t("设备与业务日志")}
|
||||||
subtitle={t("查看系统运行日志,支持过滤和搜索")}
|
subtitle={t("记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码")}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -216,7 +290,7 @@ export default function LogsPage() {
|
|||||||
>
|
>
|
||||||
{paused ? t("继续") : t("暂停")}
|
{paused ? t("继续") : t("暂停")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
|
<Button loading={clearing} onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
|
||||||
{t("清空")}
|
{t("清空")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}>
|
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}>
|
||||||
@@ -255,6 +329,13 @@ export default function LogsPage() {
|
|||||||
className="w-full sm:w-40"
|
className="w-full sm:w-40"
|
||||||
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
value={category}
|
||||||
|
onChange={(v) => setCategory(v as Category)}
|
||||||
|
placeholder={t("业务分类")}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
options={CATEGORY_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
@@ -279,7 +360,7 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LogRetentionCard />
|
<LogRetentionCard refreshKey={retentionRefreshKey} />
|
||||||
|
|
||||||
<div className="ui-card overflow-hidden">
|
<div className="ui-card overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -291,24 +372,30 @@ export default function LogsPage() {
|
|||||||
{loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")}
|
{loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{filtered.map((entry, i) => (
|
{filtered.map((entry, i) => {
|
||||||
<div key={i} className="py-0.5 hover:bg-white/5 px-2 -mx-2 rounded whitespace-nowrap">
|
const entryCategory = logCategory(entry);
|
||||||
|
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="text-gray-500">[{displayTime(entry.time)}]</span>
|
||||||
<span className={cx("font-bold ml-1.5", levelColor(entry.level))}>
|
<span className={cx("font-bold", levelColor(entry.level))}>{entry.level.toUpperCase()}</span>
|
||||||
{entry.level.toUpperCase()}
|
<span className={cx("rounded px-1.5 py-0.5 text-[11px]", categoryColor(entryCategory))}>
|
||||||
|
{t(CATEGORY_OPTIONS.find((item) => item.value === entryCategory)?.label ?? "系统错误")}
|
||||||
</span>
|
</span>
|
||||||
<span
|
{entry.caller ? <span className="max-w-48 truncate text-indigo-400" title={entry.caller}>{entry.caller}</span> : null}
|
||||||
className="text-indigo-400 inline-block max-w-48 truncate align-bottom ml-1.5"
|
<span className="break-words text-gray-100">{entry.message}</span>
|
||||||
title={entry.caller ?? ""}
|
</div>
|
||||||
>
|
|
||||||
{entry.caller ?? ""}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-100 ml-1.5">{entry.message}</span>
|
|
||||||
{entry.fields ? (
|
{entry.fields ? (
|
||||||
<span className="text-amber-300/70 ml-1.5">{fieldsText(entry.fields)}</span>
|
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export default function SmsPage() {
|
|||||||
const selectContact = useCallback(
|
const selectContact = useCallback(
|
||||||
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
|
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
|
||||||
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
|
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
|
||||||
if (!key || (keyRef.current === key && messagesRef.current.length > 0)) return;
|
if (!key) return;
|
||||||
setKey(key);
|
setKey(key);
|
||||||
if (syncRoute) syncQuery(deviceRef.current, key);
|
if (syncRoute) syncQuery(deviceRef.current, key);
|
||||||
const thread = contactsRef.current.find((t) => t.key === key) || null;
|
const thread = contactsRef.current.find((t) => t.key === key) || null;
|
||||||
@@ -338,8 +338,8 @@ export default function SmsPage() {
|
|||||||
contactsList: SmsThread[],
|
contactsList: SmsThread[],
|
||||||
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
|
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const { syncRoute = false, silent = false, scrollToBottom = false } = opts;
|
const { silent = false, scrollToBottom = false } = opts;
|
||||||
const active = contactsList.find((t) => t.key === keyRef.current) || null;
|
const active = (keyRef.current && contactsList.find((t) => t.key === keyRef.current)) || null;
|
||||||
if (active) {
|
if (active) {
|
||||||
const ok = await loadThreadFor(active, device, silent);
|
const ok = await loadThreadFor(active, device, silent);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -350,16 +350,8 @@ export default function SmsPage() {
|
|||||||
}
|
}
|
||||||
setMessagesState([]);
|
setMessagesState([]);
|
||||||
setHasMoreState(false);
|
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(
|
const clearSelection = useCallback(
|
||||||
@@ -479,7 +471,7 @@ export default function SmsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [composer, devices, refreshCurrent, scrollToBottomNow]);
|
}, [composer, devices, refreshCurrent, scrollToBottomNow, t]);
|
||||||
|
|
||||||
const openNewSms = useCallback(() => {
|
const openNewSms = useCallback(() => {
|
||||||
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
|
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
|
||||||
@@ -506,7 +498,7 @@ export default function SmsPage() {
|
|||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshCurrent],
|
[refreshCurrent, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteMessageAction = useCallback(
|
const deleteMessageAction = useCallback(
|
||||||
@@ -530,7 +522,7 @@ export default function SmsPage() {
|
|||||||
setDeletingMessageId(null);
|
setDeletingMessageId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingMessageId, refreshCurrent, clearSelection],
|
[deletingMessageId, refreshCurrent, clearSelection, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteThreadAction = useCallback(
|
const deleteThreadAction = useCallback(
|
||||||
@@ -560,7 +552,7 @@ export default function SmsPage() {
|
|||||||
setDeletingThreadKey(null);
|
setDeletingThreadKey(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingThreadKey, clearSelection, loadContacts],
|
[deletingThreadKey, clearSelection, loadContacts, lang],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeActionSheet = useCallback(() => {
|
const closeActionSheet = useCallback(() => {
|
||||||
@@ -622,16 +614,6 @@ export default function SmsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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]);
|
useEffect(() => () => clearLongPress(), [clearLongPress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+2
-1
@@ -432,12 +432,13 @@ export interface SecuritySettings {
|
|||||||
clientAllowed: boolean;
|
clientAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 运行日志保留策略:默认不限制,可按条数或天数限制。
|
// 运行日志保留策略:全局硬上限 10000 条,可配置更严格的条数或天数限制。
|
||||||
export interface LoggingSettings {
|
export interface LoggingSettings {
|
||||||
mode: "unlimited" | "count" | "days";
|
mode: "unlimited" | "count" | "days";
|
||||||
count: number;
|
count: number;
|
||||||
days: number;
|
days: number;
|
||||||
storedLogs: number;
|
storedLogs: number;
|
||||||
|
maxLogs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const {
|
|||||||
} = await import(moduleURL);
|
} = await import(moduleURL);
|
||||||
|
|
||||||
test("uses the current physical SIM when the device has no eSIM profiles", () => {
|
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"), [
|
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", () => {
|
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
|
||||||
const iccid = "89441000400128014257";
|
const iccid = "8944100000000000001";
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildAutomaticTaskProfileOptions(
|
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", () => {
|
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 savedICCID = "89104100000028106378";
|
||||||
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user